From 713ddc16780fb9cf9cf79d69d092e5261abf7ed3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:10:00 +0200 Subject: [PATCH] Fix duplicate damage combos in comp analyzer Closes #2788 --- .../core/damage-combinations.test.ts | 36 +++++++++++ .../comp-analyzer/core/damage-combinations.ts | 62 ++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/app/features/comp-analyzer/core/damage-combinations.test.ts b/app/features/comp-analyzer/core/damage-combinations.test.ts index 18b5a6dca..d8cfbdf16 100644 --- a/app/features/comp-analyzer/core/damage-combinations.test.ts +++ b/app/features/comp-analyzer/core/damage-combinations.test.ts @@ -367,6 +367,42 @@ describe("calculateDamageCombos - excessive combo filtering", () => { }); }); +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(); + 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("virtual damage combos", () => { test("Explosher has COMBO damage type combining DIRECT and DISTANCE", () => { const sources = extractDamageSources([EXPLOSHER_ID]); diff --git a/app/features/comp-analyzer/core/damage-combinations.ts b/app/features/comp-analyzer/core/damage-combinations.ts index ff0d4f5d2..6c607540c 100644 --- a/app/features/comp-analyzer/core/damage-combinations.ts +++ b/app/features/comp-analyzer/core/damage-combinations.ts @@ -303,6 +303,62 @@ function backtrack( } } +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(); + + 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, @@ -323,7 +379,9 @@ function filterAndSortCombos( return true; }); - filtered.sort((a, b) => { + 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) { @@ -332,7 +390,7 @@ function filterAndSortCombos( return a.hitCount - b.hitCount; }); - return filtered.slice(0, maxCombosDisplayed); + return deduplicated.slice(0, maxCombosDisplayed); } function hasOneShot(combo: DamageCombo): boolean {