mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 03:26:16 -05:00
Comp analyzer single weapon combos
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,5 +208,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -208,5 +208,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "מגן הקבוצה",
|
||||
"comp.specialCategory.TEAM_BUFF": "חיזוק הקבוצה",
|
||||
"comp.removeAll": "הסר הכל",
|
||||
"comp.addAll": "הוסף הכל"
|
||||
"comp.addAll": "הוסף הכל",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -202,5 +202,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -202,5 +202,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -208,5 +208,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -214,5 +214,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -211,5 +211,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -214,5 +214,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "",
|
||||
"comp.specialCategory.TEAM_BUFF": "",
|
||||
"comp.removeAll": "",
|
||||
"comp.addAll": ""
|
||||
"comp.addAll": "",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
@@ -205,5 +205,6 @@
|
||||
"comp.specialCategory.TEAM_SHIELD": "团队防护罩",
|
||||
"comp.specialCategory.TEAM_BUFF": "团队增益",
|
||||
"comp.removeAll": "移除所有",
|
||||
"comp.addAll": "添加所有"
|
||||
"comp.addAll": "添加所有",
|
||||
"comp.singleWeaponCombos": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user