mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
COMBO_DAMAGE_THRESHOLD,
|
||||
MAX_COMBOS_DISPLAYED,
|
||||
MAX_DAMAGE_TYPES_PER_COMBO,
|
||||
MAX_REPEATS_PER_DAMAGE_TYPE,
|
||||
} from "../comp-analyzer-constants";
|
||||
import {
|
||||
calculateDamageCombos,
|
||||
calculateInkTimeToKill,
|
||||
extractDamageSources,
|
||||
} from "./damage-combinations";
|
||||
|
||||
const SPLATTERSHOT_ID = 40;
|
||||
const SPLAT_ROLLER_ID = 1010;
|
||||
const SPLAT_CHARGER_ID = 2010;
|
||||
const AEROSPRAY_MG_ID = 30;
|
||||
const SPLATTERSHOT_JR_ID = 10;
|
||||
const EXPLOSHER_ID = 3040;
|
||||
|
||||
describe("extractDamageSources", () => {
|
||||
test("extracts main weapon damages", () => {
|
||||
const sources = extractDamageSources([SPLATTERSHOT_ID]);
|
||||
|
||||
expect(sources.length).toBe(1);
|
||||
expect(sources[0].weaponId).toBe(SPLATTERSHOT_ID);
|
||||
expect(sources[0].weaponSlot).toBe(0);
|
||||
|
||||
const mainDamages = sources[0].damages.filter(
|
||||
(d) => d.weaponType === "MAIN",
|
||||
);
|
||||
expect(mainDamages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("extracts sub weapon damages", () => {
|
||||
const sources = extractDamageSources([SPLATTERSHOT_ID]);
|
||||
|
||||
const subDamages = sources[0].damages.filter((d) => d.weaponType === "SUB");
|
||||
expect(subDamages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("extracts special weapon damages when applicable", () => {
|
||||
const sources = extractDamageSources([SPLATTERSHOT_ID]);
|
||||
|
||||
const specialDamages = sources[0].damages.filter(
|
||||
(d) => d.weaponType === "SPECIAL",
|
||||
);
|
||||
expect(specialDamages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("assigns correct weapon slots for multiple weapons", () => {
|
||||
const sources = extractDamageSources([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
]);
|
||||
|
||||
expect(sources.length).toBe(3);
|
||||
expect(sources[0].weaponSlot).toBe(0);
|
||||
expect(sources[1].weaponSlot).toBe(1);
|
||||
expect(sources[2].weaponSlot).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - basic combination generation", () => {
|
||||
test("generates combos involving 2+ weapons", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
expect(combos.length).toBeGreaterThan(0);
|
||||
|
||||
for (const combo of combos) {
|
||||
const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot));
|
||||
expect(uniqueSlots.size).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("calculates correct total damage", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const calculatedTotal = combo.segments.reduce(
|
||||
(sum, segment) => sum + segment.damageValue * segment.count,
|
||||
0,
|
||||
);
|
||||
expect(combo.totalDamage).toBeCloseTo(calculatedTotal, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("calculates correct hit count", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const calculatedHitCount = combo.segments.reduce(
|
||||
(sum, segment) => sum + segment.count,
|
||||
0,
|
||||
);
|
||||
expect(combo.hitCount).toBe(calculatedHitCount);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - constraint enforcement", () => {
|
||||
test("respects max damage types per combo constraint", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
AEROSPRAY_MG_ID,
|
||||
]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const uniqueTypes = new Set(combo.segments.map((s) => s.damageType));
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(MAX_DAMAGE_TYPES_PER_COMBO);
|
||||
}
|
||||
});
|
||||
|
||||
test("respects max repeats per damage type constraint", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
AEROSPRAY_MG_ID,
|
||||
]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const typeToCount = new Map<string, number>();
|
||||
for (const segment of combo.segments) {
|
||||
const current = typeToCount.get(segment.damageType) ?? 0;
|
||||
typeToCount.set(segment.damageType, current + segment.count);
|
||||
}
|
||||
|
||||
for (const [type, count] of typeToCount) {
|
||||
expect(
|
||||
count,
|
||||
`Type ${type} has ${count} uses, max is ${MAX_REPEATS_PER_DAMAGE_TYPE}`,
|
||||
).toBeLessThanOrEqual(MAX_REPEATS_PER_DAMAGE_TYPE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("requires 2+ weapon slots in each combo", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot));
|
||||
expect(uniqueSlots.size).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - threshold filtering", () => {
|
||||
test("excludes combos below damage threshold", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
for (const combo of combos) {
|
||||
expect(combo.totalDamage).toBeGreaterThanOrEqual(COMBO_DAMAGE_THRESHOLD);
|
||||
}
|
||||
});
|
||||
|
||||
test("includes combos at or above damage threshold", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
const hasValidCombos = combos.some(
|
||||
(c) => c.totalDamage >= COMBO_DAMAGE_THRESHOLD,
|
||||
);
|
||||
expect(hasValidCombos).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - one-shot exclusion", () => {
|
||||
test("excludes all combos containing a 100+ damage hit", () => {
|
||||
const combos = calculateDamageCombos([SPLAT_CHARGER_ID, SPLATTERSHOT_ID]);
|
||||
|
||||
for (const combo of combos) {
|
||||
const hasOneShot = combo.segments.some((s) => s.damageValue >= 100);
|
||||
expect(hasOneShot).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - sorting", () => {
|
||||
test("sorts results by totalDamage closest to 100 (lethal threshold)", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
]);
|
||||
|
||||
if (combos.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < combos.length - 1; i++) {
|
||||
const currentDistTo100 = Math.abs(combos[i].totalDamage - 100);
|
||||
const nextDistTo100 = Math.abs(combos[i + 1].totalDamage - 100);
|
||||
expect(currentDistTo100).toBeLessThanOrEqual(nextDistTo100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - result cap", () => {
|
||||
test("returns at most MAX_COMBOS_DISPLAYED results", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
AEROSPRAY_MG_ID,
|
||||
]);
|
||||
|
||||
expect(combos.length).toBeLessThanOrEqual(MAX_COMBOS_DISPLAYED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - edge cases", () => {
|
||||
test("returns empty array for empty weapon selection", () => {
|
||||
const combos = calculateDamageCombos([]);
|
||||
|
||||
expect(combos).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array for single weapon (cannot make cross-weapon combos)", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID]);
|
||||
|
||||
expect(combos).toEqual([]);
|
||||
});
|
||||
|
||||
test("same sub weapon on multiple weapons creates valid combos", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLATTERSHOT_JR_ID]);
|
||||
|
||||
expect(combos.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
for (const combo of combos) {
|
||||
const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot));
|
||||
expect(uniqueSlots.size).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("all segments have valid weapon slot assignments", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID, SPLAT_ROLLER_ID]);
|
||||
|
||||
for (const combo of combos) {
|
||||
for (const segment of combo.segments) {
|
||||
expect(segment.weaponSlot).toBeGreaterThanOrEqual(0);
|
||||
expect(segment.weaponSlot).toBeLessThan(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all segments have count of 1 or 2", () => {
|
||||
const combos = calculateDamageCombos([
|
||||
SPLATTERSHOT_ID,
|
||||
SPLAT_ROLLER_ID,
|
||||
SPLAT_CHARGER_ID,
|
||||
]);
|
||||
|
||||
for (const combo of combos) {
|
||||
for (const segment of combo.segments) {
|
||||
expect(segment.count).toBeGreaterThanOrEqual(1);
|
||||
expect(segment.count).toBeLessThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateInkTimeToKill", () => {
|
||||
test("returns null for combos that are already lethal (>= 100 damage)", () => {
|
||||
const result = calculateInkTimeToKill(100, 0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for combos above 100 damage", () => {
|
||||
const result = calculateInkTimeToKill(150, 0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("calculates short ink time for 99 damage combo with 0 RES", () => {
|
||||
const result = calculateInkTimeToKill(99, 0);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
expect(result).toBeLessThan(30);
|
||||
});
|
||||
|
||||
test("calculates longer ink time for 80 damage combo with 0 RES", () => {
|
||||
const result = calculateInkTimeToKill(80, 0);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
test("returns null when remaining damage exceeds ink damage limit with high RES", () => {
|
||||
const result = calculateInkTimeToKill(60, 57);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("higher RES increases ink time", () => {
|
||||
const resultNoRes = calculateInkTimeToKill(90, 0);
|
||||
const resultMaxRes = calculateInkTimeToKill(90, 57);
|
||||
|
||||
expect(resultNoRes).not.toBeNull();
|
||||
expect(resultMaxRes).not.toBeNull();
|
||||
expect(resultMaxRes!).toBeGreaterThan(resultNoRes!);
|
||||
});
|
||||
|
||||
test("handles boundary case at 0 AP", () => {
|
||||
const result = calculateInkTimeToKill(80, 0);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles boundary case at 57 AP", () => {
|
||||
const result = calculateInkTimeToKill(90, 57);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - excessive combo filtering", () => {
|
||||
test("filters out excessive combos (200+ damage where removing any hit still kills)", () => {
|
||||
const combos = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID, SPLAT_ROLLER_ID, SPLAT_CHARGER_ID, AEROSPRAY_MG_ID],
|
||||
[],
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
|
||||
const hasExcessiveCombo = combos.some((combo) => {
|
||||
const flatDamages = combo.segments.flatMap((s) =>
|
||||
Array(s.count).fill(s.damageValue),
|
||||
);
|
||||
for (const damage of flatDamages) {
|
||||
const reducedDamage = combo.totalDamage - damage;
|
||||
if (reducedDamage >= 100) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
expect(hasExcessiveCombo).toBe(false);
|
||||
});
|
||||
|
||||
test("allows combos where removing any hit drops below lethal threshold", () => {
|
||||
const combos = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID, SPLAT_ROLLER_ID],
|
||||
[],
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
|
||||
for (const combo of combos) {
|
||||
const flatDamages = combo.segments.flatMap((s) =>
|
||||
Array(s.count).fill(s.damageValue),
|
||||
);
|
||||
const allHitsNecessary = flatDamages.every(
|
||||
(damage) => combo.totalDamage - damage < 100,
|
||||
);
|
||||
expect(allHitsNecessary).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const TRI_SLOSHER_ID = 3010;
|
||||
const INKBRUSH_ID = 1100;
|
||||
const GOLD_DYNAMO_ROLLER_ID = 1021;
|
||||
const RAPID_BLASTER_PRO_WNT_R_ID = 252;
|
||||
|
||||
describe("calculateDamageCombos - deduplication", () => {
|
||||
test("no duplicate combos with bug report weapons", () => {
|
||||
const combos = calculateDamageCombos(
|
||||
[
|
||||
TRI_SLOSHER_ID,
|
||||
INKBRUSH_ID,
|
||||
GOLD_DYNAMO_ROLLER_ID,
|
||||
RAPID_BLASTER_PRO_WNT_R_ID,
|
||||
],
|
||||
[],
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
|
||||
const canonicalKeys = combos.map((combo) => {
|
||||
const grouped = new Map<string, number>();
|
||||
for (const segment of combo.segments) {
|
||||
const key = `${segment.damageType}:${segment.damageValue}`;
|
||||
grouped.set(key, (grouped.get(key) ?? 0) + segment.count);
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, count]) => `${key}:${count}`)
|
||||
.join("|");
|
||||
});
|
||||
|
||||
const uniqueKeys = new Set(canonicalKeys);
|
||||
expect(uniqueKeys.size).toBe(canonicalKeys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateDamageCombos - single weapon combos", () => {
|
||||
test("returns no combos for a single weapon when disabled", () => {
|
||||
const combos = calculateDamageCombos([SPLATTERSHOT_ID]);
|
||||
|
||||
expect(combos).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates combos from a single weapon when enabled", () => {
|
||||
const combos = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID],
|
||||
[],
|
||||
0,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(combos.length).toBeGreaterThan(0);
|
||||
|
||||
for (const combo of combos) {
|
||||
const uniqueSlots = new Set(combo.segments.map((s) => s.weaponSlot));
|
||||
expect(uniqueSlots.size).toBe(1);
|
||||
expect(combo.totalDamage).toBeGreaterThanOrEqual(COMBO_DAMAGE_THRESHOLD);
|
||||
}
|
||||
});
|
||||
|
||||
test("lets a single weapon pair its main and sub damage when enabled", () => {
|
||||
const combos = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID],
|
||||
[],
|
||||
0,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const hasMainSubCombo = combos.some((combo) => {
|
||||
const hasMain = combo.segments.some(
|
||||
(s) => !s.isSubWeapon && !s.isSpecialWeapon,
|
||||
);
|
||||
const hasSub = combo.segments.some((s) => s.isSubWeapon);
|
||||
return hasMain && hasSub;
|
||||
});
|
||||
expect(hasMainSubCombo).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps single-weapon combos alongside cross-weapon ones when enabled", () => {
|
||||
const withoutSingle = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID, SPLAT_ROLLER_ID],
|
||||
[],
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
const withSingle = calculateDamageCombos(
|
||||
[SPLATTERSHOT_ID, SPLAT_ROLLER_ID],
|
||||
[],
|
||||
0,
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(withSingle.length).toBeGreaterThanOrEqual(withoutSingle.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual damage combos", () => {
|
||||
test("Explosher has COMBO damage type combining DIRECT and DISTANCE", () => {
|
||||
const sources = extractDamageSources([EXPLOSHER_ID]);
|
||||
const damages = sources[0].damages;
|
||||
|
||||
const comboDamage = damages.find((d) => d.type === "COMBO");
|
||||
expect(comboDamage).toBeDefined();
|
||||
expect(comboDamage?.weaponType).toBe("MAIN");
|
||||
|
||||
const directDamage = damages.find((d) => d.type === "DIRECT");
|
||||
const distanceDamage = damages.find((d) => d.type === "DISTANCE");
|
||||
|
||||
expect(directDamage).toBeDefined();
|
||||
expect(distanceDamage).toBeDefined();
|
||||
|
||||
const expectedComboValue =
|
||||
(directDamage?.value ?? 0) + (distanceDamage?.value ?? 0);
|
||||
expect(comboDamage?.value).toBeCloseTo(expectedComboValue, 1);
|
||||
});
|
||||
|
||||
test("COMBO damage appears in damage combos", () => {
|
||||
const combos = calculateDamageCombos([EXPLOSHER_ID, SPLATTERSHOT_ID]);
|
||||
|
||||
const comboWithComboType = combos.find((c) =>
|
||||
c.segments.some((s) => s.damageType === "COMBO"),
|
||||
);
|
||||
expect(comboWithComboType).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { damageTypeToWeaponType } from "~/features/build-analyzer/analyzer-constants";
|
||||
import type {
|
||||
AbilityPoints,
|
||||
DamageType,
|
||||
} from "~/features/build-analyzer/analyzer-types";
|
||||
import {
|
||||
buildStats,
|
||||
subWeaponDamageValue,
|
||||
} from "~/features/build-analyzer/core/stats";
|
||||
import { weaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
COMBO_DAMAGE_THRESHOLD,
|
||||
LETHAL_DAMAGE,
|
||||
MAX_COMBOS_DISPLAYED,
|
||||
MAX_DAMAGE_TYPES_PER_COMBO,
|
||||
MAX_REPEATS_PER_DAMAGE_TYPE,
|
||||
VIRTUAL_DAMAGE_COMBOS,
|
||||
} from "../comp-analyzer-constants";
|
||||
import type {
|
||||
DamageCombo,
|
||||
DamageSegment,
|
||||
WeaponDamageSource,
|
||||
} from "../comp-analyzer-types";
|
||||
|
||||
interface DamageOption {
|
||||
weaponSlot: number;
|
||||
weaponId: MainWeaponId;
|
||||
damageType: DamageType;
|
||||
damageValue: number;
|
||||
weaponType: "MAIN" | "SUB" | "SPECIAL";
|
||||
}
|
||||
|
||||
export function extractDamageSources(
|
||||
weaponIds: MainWeaponId[],
|
||||
targetSubDefenseAp = 0,
|
||||
): WeaponDamageSource[] {
|
||||
return weaponIds.map((weaponId, slot) => {
|
||||
const stats = buildStats({ weaponSplId: weaponId, hasTacticooler: false });
|
||||
const damages: WeaponDamageSource["damages"] = [];
|
||||
|
||||
for (const damage of stats.stats.damages) {
|
||||
const weaponType = damageTypeToWeaponType[damage.type];
|
||||
if (weaponType === "MAIN") {
|
||||
damages.push({
|
||||
type: damage.type,
|
||||
value: damage.value,
|
||||
weaponType: "MAIN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const subDamage of stats.stats.subWeaponDefenseDamages) {
|
||||
if (subDamage.subWeaponId === stats.weapon.subWeaponSplId) {
|
||||
const params = weaponParams();
|
||||
const reducedValue =
|
||||
targetSubDefenseAp === 0
|
||||
? subDamage.baseValue
|
||||
: subWeaponDamageValue({
|
||||
baseValue: subDamage.baseValue,
|
||||
subWeaponId: subDamage.subWeaponId,
|
||||
abilityPoints: targetSubDefenseAp,
|
||||
params: params.subWeapons[subDamage.subWeaponId],
|
||||
});
|
||||
damages.push({
|
||||
type: subDamage.type,
|
||||
value: reducedValue,
|
||||
weaponType: "SUB",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const specialDamage of stats.stats.specialWeaponDamages) {
|
||||
damages.push({
|
||||
type: specialDamage.type,
|
||||
value: specialDamage.value,
|
||||
weaponType: "SPECIAL",
|
||||
});
|
||||
}
|
||||
|
||||
const virtualCombos = VIRTUAL_DAMAGE_COMBOS[weaponId];
|
||||
if (virtualCombos) {
|
||||
for (const combo of virtualCombos) {
|
||||
const combinedValue = combo.damageTypes.reduce((sum, type) => {
|
||||
const damage = damages.find((d) => d.type === type);
|
||||
return sum + (damage?.value ?? 0);
|
||||
}, 0);
|
||||
|
||||
if (combinedValue > 0) {
|
||||
damages.push({
|
||||
type: combo.virtualType,
|
||||
value: combinedValue,
|
||||
weaponType: "MAIN",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
weaponSlot: slot,
|
||||
weaponId,
|
||||
damages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface ExcludedDamageKey {
|
||||
weaponId: MainWeaponId;
|
||||
weaponType: "main" | "sub" | "special";
|
||||
damageType: DamageType;
|
||||
}
|
||||
|
||||
export function getAllDamageKeys(
|
||||
weaponIds: MainWeaponId[],
|
||||
targetSubDefenseAp = 0,
|
||||
): ExcludedDamageKey[] {
|
||||
const sources = extractDamageSources(weaponIds, targetSubDefenseAp);
|
||||
const seen = new Set<string>();
|
||||
const keys: ExcludedDamageKey[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
for (const damage of source.damages) {
|
||||
const weaponType = damage.weaponType.toLowerCase() as
|
||||
| "main"
|
||||
| "sub"
|
||||
| "special";
|
||||
const keyString = `${source.weaponId}-${weaponType}-${damage.type}`;
|
||||
|
||||
if (seen.has(keyString)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(keyString);
|
||||
|
||||
keys.push({
|
||||
weaponId: source.weaponId,
|
||||
weaponType,
|
||||
damageType: damage.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function calculateDamageCombos(
|
||||
weaponIds: MainWeaponId[],
|
||||
excludedKeys: ExcludedDamageKey[] = [],
|
||||
targetSubDefenseAp = 0,
|
||||
maxCombosDisplayed = MAX_COMBOS_DISPLAYED,
|
||||
includeSingleWeaponCombos = false,
|
||||
): DamageCombo[] {
|
||||
const minWeaponSlots = includeSingleWeaponCombos ? 1 : 2;
|
||||
if (weaponIds.length < minWeaponSlots) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const excludedSet = new Set(
|
||||
excludedKeys.map((k) => `${k.weaponId}-${k.weaponType}-${k.damageType}`),
|
||||
);
|
||||
|
||||
const sources = extractDamageSources(weaponIds, targetSubDefenseAp);
|
||||
const damageOptions = flattenToOptions(sources, excludedSet);
|
||||
const combos = generateCombinations(
|
||||
damageOptions,
|
||||
minWeaponSlots,
|
||||
includeSingleWeaponCombos,
|
||||
);
|
||||
const filtered = filterAndSortCombos(combos, maxCombosDisplayed);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function flattenToOptions(
|
||||
sources: WeaponDamageSource[],
|
||||
excludedSet: Set<string>,
|
||||
): DamageOption[] {
|
||||
const options: DamageOption[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
for (const damage of source.damages) {
|
||||
const weaponType = damage.weaponType.toLowerCase() as
|
||||
| "main"
|
||||
| "sub"
|
||||
| "special";
|
||||
const key = `${source.weaponId}-${weaponType}-${damage.type}`;
|
||||
|
||||
if (excludedSet.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.push({
|
||||
weaponSlot: source.weaponSlot,
|
||||
weaponId: source.weaponId,
|
||||
damageType: damage.type,
|
||||
damageValue: damage.value,
|
||||
weaponType: damage.weaponType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
interface PartialCombo {
|
||||
segments: DamageSegment[];
|
||||
totalDamage: number;
|
||||
hitCount: number;
|
||||
usedSlots: Set<number>;
|
||||
typeCountMap: Map<DamageType, number>;
|
||||
slotDamageType: Map<string, DamageType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a weapon slot's damage into a single "channel" that may only ever
|
||||
* contribute one damage type per combo. In single-weapon mode each weapon type
|
||||
* (main/sub/special) is its own channel so a single weapon can pair e.g. its
|
||||
* main damage with its sub; otherwise a whole slot is one channel.
|
||||
*/
|
||||
function slotDamageChannel(
|
||||
option: DamageOption,
|
||||
includeSingleWeaponCombos: boolean,
|
||||
): string {
|
||||
return includeSingleWeaponCombos
|
||||
? `${option.weaponSlot}-${option.weaponType}`
|
||||
: String(option.weaponSlot);
|
||||
}
|
||||
|
||||
function generateCombinations(
|
||||
options: DamageOption[],
|
||||
minWeaponSlots: number,
|
||||
includeSingleWeaponCombos: boolean,
|
||||
): DamageCombo[] {
|
||||
const results: DamageCombo[] = [];
|
||||
|
||||
const initialState: PartialCombo = {
|
||||
segments: [],
|
||||
totalDamage: 0,
|
||||
hitCount: 0,
|
||||
usedSlots: new Set(),
|
||||
typeCountMap: new Map(),
|
||||
slotDamageType: new Map(),
|
||||
};
|
||||
|
||||
backtrack(
|
||||
options,
|
||||
0,
|
||||
initialState,
|
||||
results,
|
||||
minWeaponSlots,
|
||||
includeSingleWeaponCombos,
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function backtrack(
|
||||
options: DamageOption[],
|
||||
startIndex: number,
|
||||
current: PartialCombo,
|
||||
results: DamageCombo[],
|
||||
minWeaponSlots: number,
|
||||
includeSingleWeaponCombos: boolean,
|
||||
): void {
|
||||
if (
|
||||
current.usedSlots.size >= minWeaponSlots &&
|
||||
current.totalDamage >= COMBO_DAMAGE_THRESHOLD
|
||||
) {
|
||||
results.push({
|
||||
segments: [...current.segments],
|
||||
totalDamage: current.totalDamage,
|
||||
hitCount: current.hitCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
current.typeCountMap.size >= MAX_DAMAGE_TYPES_PER_COMBO &&
|
||||
current.totalDamage >= COMBO_DAMAGE_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = startIndex; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
const currentTypeCount = current.typeCountMap.get(option.damageType) ?? 0;
|
||||
|
||||
if (
|
||||
current.typeCountMap.size >= MAX_DAMAGE_TYPES_PER_COMBO &&
|
||||
!current.typeCountMap.has(option.damageType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTypeCount >= MAX_REPEATS_PER_DAMAGE_TYPE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slotChannel = slotDamageChannel(option, includeSingleWeaponCombos);
|
||||
const existingTypeForSlot = current.slotDamageType.get(slotChannel);
|
||||
if (existingTypeForSlot && existingTypeForSlot !== option.damageType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (
|
||||
let count = 1;
|
||||
count <= Math.min(2, MAX_REPEATS_PER_DAMAGE_TYPE - currentTypeCount);
|
||||
count++
|
||||
) {
|
||||
const segment: DamageSegment = {
|
||||
weaponSlot: option.weaponSlot,
|
||||
weaponId: option.weaponId,
|
||||
damageType: option.damageType,
|
||||
damageValue: option.damageValue,
|
||||
isSubWeapon: option.weaponType === "SUB",
|
||||
isSpecialWeapon: option.weaponType === "SPECIAL",
|
||||
count,
|
||||
};
|
||||
|
||||
const newUsedSlots = new Set(current.usedSlots);
|
||||
newUsedSlots.add(option.weaponSlot);
|
||||
|
||||
const newTypeCountMap = new Map(current.typeCountMap);
|
||||
newTypeCountMap.set(option.damageType, currentTypeCount + count);
|
||||
|
||||
const newSlotDamageType = new Map(current.slotDamageType);
|
||||
newSlotDamageType.set(slotChannel, option.damageType);
|
||||
|
||||
const newState: PartialCombo = {
|
||||
segments: [...current.segments, segment],
|
||||
totalDamage: current.totalDamage + option.damageValue * count,
|
||||
hitCount: current.hitCount + count,
|
||||
usedSlots: newUsedSlots,
|
||||
typeCountMap: newTypeCountMap,
|
||||
slotDamageType: newSlotDamageType,
|
||||
};
|
||||
|
||||
backtrack(
|
||||
options,
|
||||
i + 1,
|
||||
newState,
|
||||
results,
|
||||
minWeaponSlots,
|
||||
includeSingleWeaponCombos,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCombo(combo: DamageCombo): DamageCombo {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ segment: DamageSegment; totalCount: number }
|
||||
>();
|
||||
|
||||
for (const segment of combo.segments) {
|
||||
const key = `${segment.damageType}:${segment.damageValue}`;
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.totalCount += segment.count;
|
||||
} else {
|
||||
grouped.set(key, { segment, totalCount: segment.count });
|
||||
}
|
||||
}
|
||||
|
||||
const segments: DamageSegment[] = [];
|
||||
for (const { segment, totalCount } of grouped.values()) {
|
||||
segments.push({ ...segment, count: totalCount });
|
||||
}
|
||||
|
||||
segments.sort((a, b) => {
|
||||
const typeCompare = a.damageType.localeCompare(b.damageType);
|
||||
if (typeCompare !== 0) return typeCompare;
|
||||
return a.damageValue - b.damageValue;
|
||||
});
|
||||
|
||||
return {
|
||||
segments,
|
||||
totalDamage: combo.totalDamage,
|
||||
hitCount: combo.hitCount,
|
||||
};
|
||||
}
|
||||
|
||||
function comboKey(normalized: DamageCombo): string {
|
||||
return normalized.segments
|
||||
.map((s) => `${s.damageType}:${s.damageValue}:${s.count}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function deduplicateCombos(combos: DamageCombo[]): DamageCombo[] {
|
||||
const seen = new Map<string, DamageCombo>();
|
||||
|
||||
for (const combo of combos) {
|
||||
const normalized = normalizeCombo(combo);
|
||||
const key = comboKey(normalized);
|
||||
const existing = seen.get(key);
|
||||
|
||||
if (!existing || combo.segments.length < existing.segments.length) {
|
||||
seen.set(key, combo);
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function filterAndSortCombos(
|
||||
combos: DamageCombo[],
|
||||
maxCombosDisplayed: number,
|
||||
): DamageCombo[] {
|
||||
const filtered = combos.filter((combo) => {
|
||||
if (combo.totalDamage < COMBO_DAMAGE_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOneShot(combo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExcessiveCombo(combo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const deduplicated = deduplicateCombos(filtered);
|
||||
|
||||
deduplicated.sort((a, b) => {
|
||||
const aDistTo100 = Math.abs(a.totalDamage - 100);
|
||||
const bDistTo100 = Math.abs(b.totalDamage - 100);
|
||||
if (aDistTo100 !== bDistTo100) {
|
||||
return aDistTo100 - bDistTo100;
|
||||
}
|
||||
return a.hitCount - b.hitCount;
|
||||
});
|
||||
|
||||
return deduplicated.slice(0, maxCombosDisplayed);
|
||||
}
|
||||
|
||||
function hasOneShot(combo: DamageCombo): boolean {
|
||||
return combo.segments.some((s) => s.damageValue >= LETHAL_DAMAGE);
|
||||
}
|
||||
|
||||
function isExcessiveCombo(combo: DamageCombo): boolean {
|
||||
const flatDamages = combo.segments.flatMap((s) =>
|
||||
Array(s.count).fill(s.damageValue),
|
||||
);
|
||||
const totalDamage = combo.totalDamage;
|
||||
|
||||
for (let i = 0; i < flatDamages.length; i++) {
|
||||
const damage = flatDamages[i];
|
||||
|
||||
const reducedDamage = totalDamage - damage;
|
||||
if (reducedDamage >= LETHAL_DAMAGE) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const SPLASH_O_MATIC_ID = 20;
|
||||
|
||||
/**
|
||||
* Calculates the number of frames needed for enemy ink damage to finish off
|
||||
* a target after dealing a certain amount of combo damage.
|
||||
*
|
||||
* @param comboDamage - The damage dealt by the combo
|
||||
* @param targetResAp - The target's Ink Resistance Up ability points (0-57)
|
||||
* @returns The number of frames needed, or null if the combo is already lethal
|
||||
* or ink damage cannot finish the kill (remaining damage exceeds ink damage limit)
|
||||
*/
|
||||
export function calculateInkTimeToKill(
|
||||
comboDamage: number,
|
||||
targetResAp: number,
|
||||
): number | null {
|
||||
if (comboDamage >= 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingDamage = 100 - comboDamage;
|
||||
|
||||
const abilityPoints: AbilityPoints = new Map([["RES", targetResAp]]);
|
||||
const stats = buildStats({
|
||||
weaponSplId: SPLASH_O_MATIC_ID,
|
||||
abilityPoints,
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
const damagePerSecond = stats.stats.damageTakenInEnemyInkPerSecond.value;
|
||||
const damageLimit = stats.stats.enemyInkDamageLimit.value;
|
||||
const gracePeriodFrames =
|
||||
stats.stats.framesBeforeTakingDamageInEnemyInk.value;
|
||||
|
||||
if (remainingDamage > damageLimit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const damageFrames = remainingDamage / (damagePerSecond / 60);
|
||||
const totalFrames = gracePeriodFrames + damageFrames;
|
||||
|
||||
return Math.ceil(totalFrames);
|
||||
}
|
||||
|
||||
// TBD: Advanced filtering options
|
||||
// - Distance range filtering
|
||||
// - Damage type include/exclude
|
||||
// - Hit count constraints
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
BIG_BUBBLER_ID,
|
||||
BOOYAH_BOMB_ID,
|
||||
CRAB_TANK_ID,
|
||||
SPLATTERCOLOR_SCREEN_ID,
|
||||
specialWeaponIds,
|
||||
TRIZOOKA_ID,
|
||||
WAVE_BREAKER_ID,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { getSpecialWeaponRange } from "./special-weapon-range";
|
||||
|
||||
describe("special weapon range", () => {
|
||||
test("computes a projectile range for Trizooka", () => {
|
||||
const result = getSpecialWeaponRange(TRIZOOKA_ID);
|
||||
|
||||
expect(result?.rangeType).toBe("projectile");
|
||||
expect(result?.range).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Crab Tank reaches further than Trizooka", () => {
|
||||
const crab = getSpecialWeaponRange(CRAB_TANK_ID);
|
||||
const trizooka = getSpecialWeaponRange(TRIZOOKA_ID);
|
||||
|
||||
expect(crab!.range).toBeGreaterThan(trizooka!.range);
|
||||
});
|
||||
|
||||
test("throws-and-bursts specials expose a throw range plus explosion blast", () => {
|
||||
const booyah = getSpecialWeaponRange(BOOYAH_BOMB_ID);
|
||||
|
||||
expect(booyah?.rangeType).toBe("projectile");
|
||||
expect(booyah?.range).toBeGreaterThan(0);
|
||||
expect(booyah?.blastRadius).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("returns the effect radius for area specials", () => {
|
||||
const waveBreaker = getSpecialWeaponRange(WAVE_BREAKER_ID);
|
||||
|
||||
expect(waveBreaker?.rangeType).toBe("radius");
|
||||
expect(waveBreaker?.range).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("returns null for global / utility specials without a meaningful circle", () => {
|
||||
expect(getSpecialWeaponRange(BIG_BUBBLER_ID)).toBeNull();
|
||||
expect(getSpecialWeaponRange(SPLATTERCOLOR_SCREEN_ID)).toBeNull();
|
||||
});
|
||||
|
||||
test("every special is either unsupported or has a positive finite range", () => {
|
||||
for (const id of specialWeaponIds) {
|
||||
const result = getSpecialWeaponRange(id);
|
||||
if (result === null) continue;
|
||||
|
||||
expect(Number.isFinite(result.range)).toBe(true);
|
||||
expect(result.range).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { specialWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { SpecialWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { specialWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
calculateGroundRange,
|
||||
simulateTrajectoryPoints,
|
||||
type TrajectoryParams,
|
||||
} from "./weapon-range";
|
||||
|
||||
const DEFAULT_BRAKE_AIR_RESIST = 0.36;
|
||||
const DEFAULT_BRAKE_GRAVITY = 0.07;
|
||||
const DEFAULT_BRAKE_TO_FREE_FRAME = 4;
|
||||
const DEFAULT_FREE_GRAVITY = 0.016;
|
||||
const DEFAULT_FREE_AIR_RESIST = 0;
|
||||
|
||||
export interface SpecialWeaponRangeResult {
|
||||
/** Radius of the range circle, in game distance units. */
|
||||
range: number;
|
||||
/** Extra outer radius covered by the projectile's blast, in game distance units. */
|
||||
blastRadius?: number;
|
||||
rangeType: "projectile" | "radius";
|
||||
}
|
||||
|
||||
/**
|
||||
* Range circle definition for a special weapon, or `null` when the special has no meaningful
|
||||
* range to draw. The underlying values come from `weapon-params.ts` (populated by
|
||||
* `scripts/create-analyzer-json.ts`): projectile specials reuse the main weapon trajectory
|
||||
* model, the rest carry a single effect radius.
|
||||
*/
|
||||
export function getSpecialWeaponRange(
|
||||
specialWeaponId: SpecialWeaponId,
|
||||
): SpecialWeaponRangeResult | null {
|
||||
const params = specialWeaponParams(specialWeaponId);
|
||||
|
||||
if (params.Range_Radius !== undefined) {
|
||||
return { range: params.Range_Radius, rangeType: "radius" };
|
||||
}
|
||||
|
||||
if (params.Range_Distance !== undefined) {
|
||||
return {
|
||||
range: params.Range_Distance,
|
||||
blastRadius: params.Range_BlastRadius,
|
||||
rangeType: "projectile",
|
||||
};
|
||||
}
|
||||
|
||||
if (params.Range_SpawnSpeed === undefined) return null;
|
||||
|
||||
const trajectoryParams: TrajectoryParams = {
|
||||
spawnSpeed: params.Range_SpawnSpeed,
|
||||
goStraightStateEndMaxSpeed:
|
||||
params.Range_GoStraightStateEndMaxSpeed ?? params.Range_SpawnSpeed,
|
||||
goStraightToBrakeStateFrame: params.Range_GoStraightToBrakeStateFrame ?? 4,
|
||||
freeGravity: params.Range_FreeGravity ?? DEFAULT_FREE_GRAVITY,
|
||||
freeAirResist: params.Range_FreeAirResist ?? DEFAULT_FREE_AIR_RESIST,
|
||||
brakeAirResist: params.Range_BrakeAirResist ?? DEFAULT_BRAKE_AIR_RESIST,
|
||||
brakeGravity: params.Range_BrakeGravity ?? DEFAULT_BRAKE_GRAVITY,
|
||||
brakeToFreeFrame:
|
||||
params.Range_BrakeToFreeStateFrame ?? DEFAULT_BRAKE_TO_FREE_FRAME,
|
||||
};
|
||||
|
||||
const range = calculateGroundRange(
|
||||
simulateTrajectoryPoints(trajectoryParams),
|
||||
);
|
||||
|
||||
return {
|
||||
range,
|
||||
blastRadius: params.Range_BlastRadius,
|
||||
rangeType: "projectile",
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpecialWeaponWithRange extends SpecialWeaponRangeResult {
|
||||
specialWeaponId: SpecialWeaponId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every special weapon that has a range to draw, widest first. Specials without a meaningful
|
||||
* range circle are omitted.
|
||||
*/
|
||||
export function getSpecialsWithRange(): SpecialWeaponWithRange[] {
|
||||
return specialWeaponIds
|
||||
.flatMap((specialWeaponId): SpecialWeaponWithRange[] => {
|
||||
const result = getSpecialWeaponRange(specialWeaponId);
|
||||
return result ? [{ specialWeaponId, ...result }] : [];
|
||||
})
|
||||
.sort((a, b) => b.range - a.range);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { getWeaponsWithRange } from "./weapon-range";
|
||||
|
||||
// [longerRangeWeaponId, shorterRangeWeaponId]
|
||||
const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [
|
||||
[220, 210], // Range Blaster > Blaster
|
||||
[70, 40], // Splattershot Pro > Splattershot
|
||||
[7010, 7030], // Tri-Stringer > Wellstring V
|
||||
[2020, 7030], // Splatterscope > Wellstring V
|
||||
[2070, 7010], // Snipewriter 5H > Tri-Stringer
|
||||
];
|
||||
|
||||
describe("weapon range comparisons", () => {
|
||||
test.each(RANGE_COMPARISONS)(
|
||||
"weapon %i has more range than weapon %i",
|
||||
(longerId, shorterId) => {
|
||||
const [longer] = getWeaponsWithRange([longerId]);
|
||||
const [shorter] = getWeaponsWithRange([shorterId]);
|
||||
|
||||
expect(longer.range).toBeGreaterThan(shorter.range);
|
||||
},
|
||||
);
|
||||
});
|
||||
257
apps/web-react/app/features/comp-analyzer/core/weapon-range.ts
Normal file
257
apps/web-react/app/features/comp-analyzer/core/weapon-range.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
|
||||
export interface TrajectoryParams {
|
||||
spawnSpeed: number;
|
||||
goStraightStateEndMaxSpeed: number;
|
||||
goStraightToBrakeStateFrame: number;
|
||||
freeGravity: number;
|
||||
freeAirResist: number;
|
||||
brakeAirResist: number;
|
||||
brakeGravity: number;
|
||||
brakeToFreeFrame: number;
|
||||
burstFrame?: number;
|
||||
bounceAfterMaxSpeed?: number;
|
||||
}
|
||||
|
||||
export interface TrajectoryPoint {
|
||||
z: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BRAKE_AIR_RESIST = 0.36;
|
||||
const DEFAULT_BRAKE_GRAVITY = 0.07;
|
||||
const DEFAULT_BRAKE_TO_FREE_FRAME = 4;
|
||||
const DEFAULT_FREE_GRAVITY = 0.016;
|
||||
const DEFAULT_FREE_AIR_RESIST = 0;
|
||||
|
||||
function getWeaponCategoryName(weaponId: MainWeaponId): string | undefined {
|
||||
for (const category of weaponCategories) {
|
||||
if (category.weaponIds.some((id) => id === weaponId)) {
|
||||
return category.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const PLAYER_HEIGHT = 1.0;
|
||||
|
||||
export function calculateGroundRange(trajectory: TrajectoryPoint[]): number {
|
||||
for (let i = 1; i < trajectory.length; i++) {
|
||||
const point = trajectory[i];
|
||||
const prevPoint = trajectory[i - 1];
|
||||
if (point.y < 0 && prevPoint.y >= 0) {
|
||||
const t = prevPoint.y / (prevPoint.y - point.y);
|
||||
return prevPoint.z + t * (point.z - prevPoint.z);
|
||||
}
|
||||
}
|
||||
const lastPoint = trajectory[trajectory.length - 1];
|
||||
return lastPoint?.z ?? 0;
|
||||
}
|
||||
|
||||
function calculateBouncingRange(trajectory: TrajectoryPoint[]): number {
|
||||
const lastPoint = trajectory[trajectory.length - 1];
|
||||
return lastPoint?.z ?? 0;
|
||||
}
|
||||
|
||||
export function simulateTrajectoryPoints(
|
||||
params: TrajectoryParams,
|
||||
): TrajectoryPoint[] {
|
||||
const {
|
||||
spawnSpeed,
|
||||
goStraightStateEndMaxSpeed,
|
||||
goStraightToBrakeStateFrame,
|
||||
freeGravity,
|
||||
freeAirResist,
|
||||
brakeAirResist,
|
||||
brakeGravity,
|
||||
brakeToFreeFrame,
|
||||
burstFrame,
|
||||
bounceAfterMaxSpeed,
|
||||
} = params;
|
||||
|
||||
const isBouncing = bounceAfterMaxSpeed !== undefined;
|
||||
const maxBounces =
|
||||
isBouncing && burstFrame !== undefined ? burstFrame : undefined;
|
||||
const maxFrames = isBouncing ? 300 : (burstFrame ?? 300);
|
||||
|
||||
const points: TrajectoryPoint[] = [];
|
||||
let z = 0;
|
||||
let y = PLAYER_HEIGHT;
|
||||
let vz = spawnSpeed;
|
||||
let vy = 0;
|
||||
let frame = 0;
|
||||
let bounceCount = 0;
|
||||
|
||||
points.push({ z, y });
|
||||
|
||||
while (frame < goStraightToBrakeStateFrame && frame < maxFrames) {
|
||||
z += vz;
|
||||
points.push({ z, y });
|
||||
frame++;
|
||||
}
|
||||
|
||||
vz = Math.min(vz, goStraightStateEndMaxSpeed);
|
||||
|
||||
for (let i = 0; i < brakeToFreeFrame && frame < maxFrames; i++) {
|
||||
vz *= 1 - brakeAirResist;
|
||||
vy = (1 - brakeAirResist) * vy - brakeGravity;
|
||||
z += vz;
|
||||
y += vy;
|
||||
points.push({ z, y });
|
||||
frame++;
|
||||
|
||||
if (y < 0) {
|
||||
if (isBouncing) {
|
||||
bounceCount++;
|
||||
if (maxBounces !== undefined && bounceCount >= maxBounces) {
|
||||
return points;
|
||||
}
|
||||
y = Math.abs(y);
|
||||
vy = Math.abs(vy);
|
||||
vz *= bounceAfterMaxSpeed;
|
||||
} else {
|
||||
return points;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (frame < maxFrames) {
|
||||
vz *= 1 - freeAirResist;
|
||||
vy = (1 - freeAirResist) * vy - freeGravity;
|
||||
z += vz;
|
||||
y += vy;
|
||||
|
||||
if (y < 0) {
|
||||
if (isBouncing) {
|
||||
bounceCount++;
|
||||
if (maxBounces !== undefined && bounceCount >= maxBounces) {
|
||||
points.push({ z, y: 0 });
|
||||
return points;
|
||||
}
|
||||
y = Math.abs(y);
|
||||
vy = Math.abs(vy);
|
||||
vz *= bounceAfterMaxSpeed;
|
||||
} else {
|
||||
points.push({ z, y });
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
points.push({ z, y });
|
||||
frame++;
|
||||
|
||||
if (!isBouncing && y < 0) {
|
||||
return points;
|
||||
}
|
||||
|
||||
if (vz < 0.01) {
|
||||
return points;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export interface WeaponRangeResult {
|
||||
range: number;
|
||||
blastRadius?: number;
|
||||
rangeType: "calculated" | "direct" | "unsupported";
|
||||
trajectory?: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export function getWeaponRange(weaponId: MainWeaponId): WeaponRangeResult {
|
||||
const category = getWeaponCategoryName(weaponId);
|
||||
|
||||
if (!category) {
|
||||
return { range: 0, rangeType: "unsupported" };
|
||||
}
|
||||
|
||||
const params = mainWeaponParams(weaponId);
|
||||
|
||||
if (category === "CHARGERS" && params.DistanceFullCharge !== undefined) {
|
||||
const range = params.DistanceFullCharge;
|
||||
return {
|
||||
range,
|
||||
rangeType: "direct",
|
||||
trajectory: [
|
||||
{ z: 0, y: PLAYER_HEIGHT },
|
||||
{ z: range, y: PLAYER_HEIGHT },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (params.Range_SpawnSpeed === undefined) {
|
||||
return { range: 0, rangeType: "unsupported" };
|
||||
}
|
||||
|
||||
const trajectoryParams: TrajectoryParams = {
|
||||
spawnSpeed: params.Range_SpawnSpeed,
|
||||
goStraightStateEndMaxSpeed:
|
||||
params.Range_GoStraightStateEndMaxSpeed ?? params.Range_SpawnSpeed,
|
||||
goStraightToBrakeStateFrame: params.Range_GoStraightToBrakeStateFrame ?? 4,
|
||||
freeGravity: params.Range_FreeGravity ?? DEFAULT_FREE_GRAVITY,
|
||||
freeAirResist: params.Range_FreeAirResist ?? DEFAULT_FREE_AIR_RESIST,
|
||||
brakeAirResist: params.Range_BrakeAirResist ?? DEFAULT_BRAKE_AIR_RESIST,
|
||||
brakeGravity: params.Range_BrakeGravity ?? DEFAULT_BRAKE_GRAVITY,
|
||||
brakeToFreeFrame:
|
||||
params.Range_BrakeToFreeStateFrame ?? DEFAULT_BRAKE_TO_FREE_FRAME,
|
||||
burstFrame: params.Range_BurstFrame,
|
||||
bounceAfterMaxSpeed: params.Range_BounceAfterMaxSpeed,
|
||||
};
|
||||
|
||||
const trajectory = simulateTrajectoryPoints(trajectoryParams);
|
||||
const range =
|
||||
params.Range_BounceAfterMaxSpeed !== undefined
|
||||
? calculateBouncingRange(trajectory)
|
||||
: calculateGroundRange(trajectory);
|
||||
|
||||
return {
|
||||
range,
|
||||
blastRadius: params.BlastRadius,
|
||||
rangeType: "calculated",
|
||||
trajectory,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeaponWithRange {
|
||||
weaponId: MainWeaponId;
|
||||
range: number;
|
||||
blastRadius?: number;
|
||||
rangeType: "calculated" | "direct" | "unsupported";
|
||||
trajectory?: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export function getWeaponsWithRange(
|
||||
weaponIds: MainWeaponId[],
|
||||
): WeaponWithRange[] {
|
||||
return weaponIds
|
||||
.map((weaponId) => {
|
||||
const result = getWeaponRange(weaponId);
|
||||
return {
|
||||
weaponId,
|
||||
...result,
|
||||
};
|
||||
})
|
||||
.filter((w) => w.rangeType !== "unsupported");
|
||||
}
|
||||
|
||||
export const BENCHMARK_WEAPON_IDS = [40, 2070] satisfies MainWeaponId[];
|
||||
|
||||
export interface BenchmarkTrajectory {
|
||||
id: MainWeaponId;
|
||||
range: number;
|
||||
trajectory?: TrajectoryPoint[];
|
||||
}
|
||||
|
||||
export function getBenchmarkTrajectories(): BenchmarkTrajectory[] {
|
||||
return BENCHMARK_WEAPON_IDS.map((weaponId) => {
|
||||
const result = getWeaponRange(weaponId);
|
||||
return {
|
||||
id: weaponId,
|
||||
range: result.range,
|
||||
trajectory: result.trajectory,
|
||||
};
|
||||
}).filter((b) => b.trajectory !== undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user