Comp analyzer single weapon combos

This commit is contained in:
Kalle
2026-07-11 16:41:35 +03:00
parent b5c778e89a
commit 88aedaee81
21 changed files with 184 additions and 27 deletions

View File

@@ -43,6 +43,15 @@ export function useSelectedWeapons() {
});
}
export function useSingleWeaponCombos() {
return useSearchParamStateEncoder<boolean>({
defaultValue: false,
name: "singleCombos",
revive: (value) => value === "true",
encode: (val) => String(val),
});
}
export function useTargetSubDefenseAp() {
return useSearchParamStateEncoder<number>({
defaultValue: 0,

View File

@@ -167,6 +167,13 @@
color: var(--color-text-high);
}
.singleWeaponToggleRow {
display: flex;
padding-block-end: var(--s-3);
border-block-end: 1px solid var(--color-border);
margin-block-end: var(--s-1);
}
.filterControlsRow {
display: flex;
gap: var(--s-2);

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SendouSwitch } from "~/components/elements/Switch";
import { Image, WeaponImage } from "~/components/Image";
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
import { mainWeaponParams } from "~/features/build-analyzer/core/utils";
@@ -14,7 +15,11 @@ import {
subWeaponImageUrl,
} from "~/utils/urls";
import { LETHAL_DAMAGE } from "../comp-analyzer-constants";
import { useTargetResAp, useTargetSubDefenseAp } from "../comp-analyzer-hooks";
import {
useSingleWeaponCombos,
useTargetResAp,
useTargetSubDefenseAp,
} from "../comp-analyzer-hooks";
import type { DamageCombo, DamageSegment } from "../comp-analyzer-types";
import {
calculateDamageCombos,
@@ -268,6 +273,10 @@ export function DamageComboList({ weaponIds }: DamageComboListProps) {
initialTargetSubDefenseAp,
);
const [isCollapsed, setIsCollapsed] = useState(false);
const [singleWeaponCombos, setSingleWeaponCombos] = useSingleWeaponCombos();
const singleWeaponOnly = weaponIds.length === 1;
const includeSingleWeaponCombos = singleWeaponOnly || singleWeaponCombos;
const allDamageKeys = getAllDamageKeys(weaponIds, targetSubDefenseAp);
const allDamageKeyStrings = new Set(allDamageKeys.map(filterKeyToString));
@@ -279,9 +288,11 @@ export function DamageComboList({ weaponIds }: DamageComboListProps) {
weaponIds,
validExcludedKeys,
targetSubDefenseAp,
undefined,
includeSingleWeaponCombos,
);
if (weaponIds.length < 2) {
if (weaponIds.length < 1) {
return null;
}
@@ -357,6 +368,15 @@ export function DamageComboList({ weaponIds }: DamageComboListProps) {
/>
<span className={styles.resSliderValue}>{targetResAp} AP</span>
</div>
<div className={styles.singleWeaponToggleRow}>
<SendouSwitch
isSelected={includeSingleWeaponCombos}
onChange={setSingleWeaponCombos}
isDisabled={singleWeaponOnly}
>
{t("analyzer:comp.singleWeaponCombos")}
</SendouSwitch>
</div>
<div className={styles.filterControlsRow}>
<button
type="button"

View File

@@ -403,6 +403,69 @@ describe("calculateDamageCombos - deduplication", () => {
});
});
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]);

View File

@@ -147,8 +147,10 @@ export function calculateDamageCombos(
excludedKeys: ExcludedDamageKey[] = [],
targetSubDefenseAp = 0,
maxCombosDisplayed = MAX_COMBOS_DISPLAYED,
includeSingleWeaponCombos = false,
): DamageCombo[] {
if (weaponIds.length < 2) {
const minWeaponSlots = includeSingleWeaponCombos ? 1 : 2;
if (weaponIds.length < minWeaponSlots) {
return [];
}
@@ -158,7 +160,11 @@ export function calculateDamageCombos(
const sources = extractDamageSources(weaponIds, targetSubDefenseAp);
const damageOptions = flattenToOptions(sources, excludedSet);
const combos = generateCombinations(damageOptions);
const combos = generateCombinations(
damageOptions,
minWeaponSlots,
includeSingleWeaponCombos,
);
const filtered = filterAndSortCombos(combos, maxCombosDisplayed);
return filtered;
@@ -201,10 +207,29 @@ interface PartialCombo {
hitCount: number;
usedSlots: Set<number>;
typeCountMap: Map<DamageType, number>;
slotDamageType: Map<number, DamageType>;
slotDamageType: Map<string, DamageType>;
}
function generateCombinations(options: DamageOption[]): DamageCombo[] {
/**
* 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 = {
@@ -216,7 +241,14 @@ function generateCombinations(options: DamageOption[]): DamageCombo[] {
slotDamageType: new Map(),
};
backtrack(options, 0, initialState, results);
backtrack(
options,
0,
initialState,
results,
minWeaponSlots,
includeSingleWeaponCombos,
);
return results;
}
@@ -226,9 +258,11 @@ function backtrack(
startIndex: number,
current: PartialCombo,
results: DamageCombo[],
minWeaponSlots: number,
includeSingleWeaponCombos: boolean,
): void {
if (
current.usedSlots.size >= 2 &&
current.usedSlots.size >= minWeaponSlots &&
current.totalDamage >= COMBO_DAMAGE_THRESHOLD
) {
results.push({
@@ -260,7 +294,8 @@ function backtrack(
continue;
}
const existingTypeForSlot = current.slotDamageType.get(option.weaponSlot);
const slotChannel = slotDamageChannel(option, includeSingleWeaponCombos);
const existingTypeForSlot = current.slotDamageType.get(slotChannel);
if (existingTypeForSlot && existingTypeForSlot !== option.damageType) {
continue;
}
@@ -287,7 +322,7 @@ function backtrack(
newTypeCountMap.set(option.damageType, currentTypeCount + count);
const newSlotDamageType = new Map(current.slotDamageType);
newSlotDamageType.set(option.weaponSlot, option.damageType);
newSlotDamageType.set(slotChannel, option.damageType);
const newState: PartialCombo = {
segments: [...current.segments, segment],
@@ -298,7 +333,14 @@ function backtrack(
slotDamageType: newSlotDamageType,
};
backtrack(options, i + 1, newState, results);
backtrack(
options,
i + 1,
newState,
results,
minWeaponSlots,
includeSingleWeaponCombos,
);
}
}
}

View File

@@ -208,5 +208,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -208,5 +208,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -208,5 +208,6 @@
"comp.specialCategory.TEAM_SHIELD": "Team Shield",
"comp.specialCategory.TEAM_BUFF": "Team Buff",
"comp.removeAll": "Remove all",
"comp.addAll": "Add all"
"comp.addAll": "Add all",
"comp.singleWeaponCombos": "Single weapon combos"
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "Escudo de equipo",
"comp.specialCategory.TEAM_BUFF": "Mejora de equipo",
"comp.removeAll": "Eliminar todos",
"comp.addAll": "Añadir todos"
"comp.addAll": "Añadir todos",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "מגן הקבוצה",
"comp.specialCategory.TEAM_BUFF": "חיזוק הקבוצה",
"comp.removeAll": "הסר הכל",
"comp.addAll": "הוסף הכל"
"comp.addAll": "הוסף הכל",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -202,5 +202,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -202,5 +202,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -208,5 +208,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -214,5 +214,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -211,5 +211,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -214,5 +214,6 @@
"comp.specialCategory.TEAM_SHIELD": "",
"comp.specialCategory.TEAM_BUFF": "",
"comp.removeAll": "",
"comp.addAll": ""
"comp.addAll": "",
"comp.singleWeaponCombos": ""
}

View File

@@ -205,5 +205,6 @@
"comp.specialCategory.TEAM_SHIELD": "团队防护罩",
"comp.specialCategory.TEAM_BUFF": "团队增益",
"comp.removeAll": "移除所有",
"comp.addAll": "添加所有"
"comp.addAll": "添加所有",
"comp.singleWeaponCombos": ""
}