feat(database): add gearTxt field to parsed results and update related functions
This commit is contained in:
@@ -10,14 +10,19 @@ import {
|
||||
Layout,
|
||||
Modal,
|
||||
Pagination,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Slider,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
||||
import * as App from '../../wailsjs/go/service/App';
|
||||
import {EventsOn} from '../../wailsjs/runtime';
|
||||
import {useMessage} from '../utils/useMessage';
|
||||
import {
|
||||
BonusStats,
|
||||
@@ -30,17 +35,28 @@ import {
|
||||
emptyBonusStats,
|
||||
emptyFilters,
|
||||
emptySetFilters,
|
||||
isFourPieceSet,
|
||||
isTwoPieceSet,
|
||||
getSetFilterPieceTotal,
|
||||
isValidSetFilterSelection,
|
||||
} from '../utils/optimizer';
|
||||
import './optimizer.css';
|
||||
|
||||
const {Content} = Layout;
|
||||
const MAX_ITEMS_PER_SLOT = 150;
|
||||
const MAX_COMBOS = 200000;
|
||||
const MAX_RESULTS = 200;
|
||||
const {Text} = Typography;
|
||||
const MAX_ITEMS_PER_SLOT = 300;
|
||||
const MAX_COMBOS = 2000000;
|
||||
const MAX_RESULTS = 5000;
|
||||
const RESULT_TABLE_SCROLL_X = 866;
|
||||
const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'] as const;
|
||||
|
||||
const GEAR_LABELS: Record<string, string> = {
|
||||
Weapon: '武器',
|
||||
Helmet: '头盔',
|
||||
Armor: '铠甲',
|
||||
Necklace: '项链',
|
||||
Ring: '戒指',
|
||||
Boots: '鞋子',
|
||||
};
|
||||
|
||||
type BuildResult = {
|
||||
key: string;
|
||||
sets: string;
|
||||
@@ -56,6 +72,14 @@ type BuildResult = {
|
||||
items: RawItem[];
|
||||
};
|
||||
|
||||
type OptimizerPreferenceOptions = {
|
||||
setFilters: SetFilters;
|
||||
statFilters: Filters;
|
||||
mainStatFilters: {necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'};
|
||||
weightValues: Record<StatKey, number>;
|
||||
resultFilters: Filters;
|
||||
};
|
||||
|
||||
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
||||
{key: 'atk', label: '\u653b\u51fb'},
|
||||
{key: 'def', label: '\u9632\u5fa1'},
|
||||
@@ -67,6 +91,107 @@ const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
||||
{key: 'res', label: '\u62b5\u6297'},
|
||||
];
|
||||
|
||||
const WEIGHT_OPTIONS: Array<{key: StatKey; label: string; icon: string}> = [
|
||||
{key: 'atk', label: '攻击', icon: '攻'},
|
||||
{key: 'def', label: '防御', icon: '防'},
|
||||
{key: 'hp', label: '生命', icon: '生'},
|
||||
{key: 'spd', label: '速度', icon: '速'},
|
||||
{key: 'cr', label: '暴击', icon: '暴'},
|
||||
{key: 'cd', label: '爆伤', icon: '伤'},
|
||||
{key: 'acc', label: '命中', icon: '命'},
|
||||
{key: 'res', label: '抵抗', icon: '抗'},
|
||||
];
|
||||
|
||||
const createEmptyResultFilters = (): Filters => ({
|
||||
atk: {},
|
||||
def: {},
|
||||
hp: {},
|
||||
spd: {},
|
||||
cr: {},
|
||||
cd: {},
|
||||
acc: {},
|
||||
res: {},
|
||||
});
|
||||
|
||||
const STAT_TYPE_LABELS: Record<string, string> = {
|
||||
Attack: '攻击力',
|
||||
Defense: '防御力',
|
||||
Health: '生命值',
|
||||
Speed: '速度',
|
||||
AttackPercent: '攻击力',
|
||||
DefensePercent: '防御力',
|
||||
HealthPercent: '生命值',
|
||||
CriticalHitChancePercent: '暴击率',
|
||||
CriticalHitDamagePercent: '暴击伤害',
|
||||
EffectivenessPercent: '效果命中',
|
||||
EffectResistancePercent: '效果抗性',
|
||||
DualAttackChancePercent: '夹攻率',
|
||||
};
|
||||
|
||||
const PERCENT_STAT_TYPES = new Set([
|
||||
'AttackPercent',
|
||||
'DefensePercent',
|
||||
'HealthPercent',
|
||||
'CriticalHitChancePercent',
|
||||
'CriticalHitDamagePercent',
|
||||
'EffectivenessPercent',
|
||||
'EffectResistancePercent',
|
||||
'DualAttackChancePercent',
|
||||
]);
|
||||
|
||||
const formatItemStat = (stat?: {type?: string; value?: number}) => {
|
||||
if (!stat?.type) return '-';
|
||||
const label = STAT_TYPE_LABELS[stat.type] || stat.type;
|
||||
const rawValue = Number(stat.value ?? 0);
|
||||
const rounded = Math.round(rawValue * 10) / 10;
|
||||
const valueText = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
return PERCENT_STAT_TYPES.has(stat.type) ? `${label} ${valueText}%` : `${label} ${valueText}`;
|
||||
};
|
||||
|
||||
const createDefaultMainStatFilters = (): OptimizerPreferenceOptions['mainStatFilters'] => ({
|
||||
necklace: 'All',
|
||||
ring: 'All',
|
||||
boots: 'All',
|
||||
});
|
||||
|
||||
const createDefaultWeightValues = (): Record<StatKey, number> => ({
|
||||
atk: 0,
|
||||
def: 0,
|
||||
hp: 0,
|
||||
spd: 0,
|
||||
cr: 0,
|
||||
cd: 0,
|
||||
acc: 0,
|
||||
res: 0,
|
||||
});
|
||||
|
||||
const cloneFilters = (filters: Partial<Record<StatKey, {min?: number; max?: number}>> = {}): Filters => {
|
||||
return STAT_LABELS.reduce((out, stat) => {
|
||||
out[stat.key] = {...(filters[stat.key] || {})};
|
||||
return out;
|
||||
}, {} as Filters);
|
||||
};
|
||||
|
||||
const cloneSetFilters = (filters: Partial<SetFilters> = {}): SetFilters => ({
|
||||
set1: [...(filters.set1 || [])],
|
||||
set2: [...(filters.set2 || [])],
|
||||
set3: [...(filters.set3 || [])],
|
||||
});
|
||||
|
||||
const defaultOptimizerPreferenceOptions = (): OptimizerPreferenceOptions => ({
|
||||
setFilters: cloneSetFilters(emptySetFilters),
|
||||
statFilters: cloneFilters(emptyFilters),
|
||||
mainStatFilters: createDefaultMainStatFilters(),
|
||||
weightValues: createDefaultWeightValues(),
|
||||
resultFilters: createEmptyResultFilters(),
|
||||
});
|
||||
|
||||
const orderItemsByGearSlot = (items: RawItem[]) => {
|
||||
return GEAR_SLOTS
|
||||
.map(slot => items.find(item => item.gear === slot))
|
||||
.filter((item): item is RawItem => Boolean(item));
|
||||
};
|
||||
|
||||
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
||||
if (!hero) return 0;
|
||||
switch (key) {
|
||||
@@ -91,6 +216,16 @@ const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
||||
}
|
||||
};
|
||||
|
||||
const passesResultFilters = (result: BuildResult, filters: Filters) => {
|
||||
return STAT_LABELS.every(stat => {
|
||||
const range = filters[stat.key];
|
||||
const value = Number(result[stat.key] ?? 0);
|
||||
if (range.min !== undefined && value < range.min) return false;
|
||||
if (range.max !== undefined && value > range.max) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export default function OptimizerPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [parsedItems, setParsedItems] = useState<RawItem[]>([]);
|
||||
@@ -98,28 +233,21 @@ export default function OptimizerPage() {
|
||||
const [selectedHeroId, setSelectedHeroId] = useState<string | number | null>(null);
|
||||
const [setFilters, setSetFilters] = useState<SetFilters>(emptySetFilters);
|
||||
const [statFilters, setStatFilters] = useState<Filters>(emptyFilters);
|
||||
const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({
|
||||
necklace: 'All',
|
||||
ring: 'All',
|
||||
boots: 'All',
|
||||
});
|
||||
const [weightValues, setWeightValues] = useState<Record<StatKey, number>>({
|
||||
atk: 3,
|
||||
def: 3,
|
||||
hp: 3,
|
||||
spd: 3,
|
||||
cr: 3,
|
||||
cd: 3,
|
||||
acc: 3,
|
||||
res: 3,
|
||||
});
|
||||
const [mainStatFilters, setMainStatFilters] = useState<OptimizerPreferenceOptions['mainStatFilters']>(createDefaultMainStatFilters);
|
||||
const [weightValues, setWeightValues] = useState<Record<StatKey, number>>(createDefaultWeightValues);
|
||||
const [results, setResults] = useState<BuildResult[]>([]);
|
||||
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
||||
const [totalCombos, setTotalCombos] = useState(0);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [optimizeProgress, setOptimizeProgress] = useState({checked: 0, matched: 0, total: 0, percent: 0});
|
||||
const [resultFilters, setResultFilters] = useState<Filters>(() => createEmptyResultFilters());
|
||||
const {success, error, info} = useMessage();
|
||||
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
|
||||
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
|
||||
const [bonusDraft, setBonusDraft] = useState<BonusStats>(emptyBonusStats);
|
||||
const [rememberPreferences, setRememberPreferences] = useState(false);
|
||||
const preferenceLoadRef = useRef({initialized: false, applying: false, skipNextSave: false, heroKey: ''});
|
||||
const preferenceSaveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const leftPanelsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | null>(null);
|
||||
@@ -336,6 +464,40 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
|
||||
const selectedHeroKey = selectedHeroId == null ? '' : String(selectedHeroId);
|
||||
const selectedHeroBonus = bonusByHeroId[selectedHeroKey] || emptyBonusStats;
|
||||
const visibleResults = useMemo(() => {
|
||||
return results.filter(result => passesResultFilters(result, resultFilters));
|
||||
}, [results, resultFilters]);
|
||||
|
||||
const buildPreferenceOptions = (): OptimizerPreferenceOptions => ({
|
||||
setFilters: cloneSetFilters(setFilters),
|
||||
statFilters: cloneFilters(statFilters),
|
||||
mainStatFilters: {...mainStatFilters},
|
||||
weightValues: {...weightValues},
|
||||
resultFilters: cloneFilters(resultFilters),
|
||||
});
|
||||
|
||||
const applyPreferenceOptions = (options?: Partial<OptimizerPreferenceOptions>) => {
|
||||
const defaults = defaultOptimizerPreferenceOptions();
|
||||
setSetFilters(options?.setFilters ? cloneSetFilters(options.setFilters) : defaults.setFilters);
|
||||
setStatFilters(options?.statFilters ? cloneFilters(options.statFilters) : defaults.statFilters);
|
||||
setMainStatFilters({
|
||||
...defaults.mainStatFilters,
|
||||
...(options?.mainStatFilters || {}),
|
||||
});
|
||||
setWeightValues({
|
||||
...defaults.weightValues,
|
||||
...(options?.weightValues || {}),
|
||||
});
|
||||
setResultFilters(options?.resultFilters ? cloneFilters(options.resultFilters) : defaults.resultFilters);
|
||||
setResults([]);
|
||||
setSelectedResult(null);
|
||||
setTotalCombos(0);
|
||||
};
|
||||
|
||||
const saveCurrentPreference = async (heroKey: string) => {
|
||||
if (!heroKey) return;
|
||||
await App.SaveOptimizerPreference(heroKey, JSON.stringify(buildPreferenceOptions()));
|
||||
};
|
||||
|
||||
const loadLatestData = async () => {
|
||||
setLoading(true);
|
||||
@@ -388,6 +550,146 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
loadLatestData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
App.GetAppSetting('optimizer.rememberPreferences')
|
||||
.then(value => {
|
||||
if (!canceled) {
|
||||
setRememberPreferences(value === 'true');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Load optimizer remember setting error:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) {
|
||||
preferenceLoadRef.current.initialized = true;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferenceLoadRef.current.initialized || !selectedHeroKey) {
|
||||
return;
|
||||
}
|
||||
if (!rememberPreferences) {
|
||||
return;
|
||||
}
|
||||
if (preferenceLoadRef.current.heroKey === selectedHeroKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
preferenceLoadRef.current.applying = true;
|
||||
App.GetOptimizerPreference(selectedHeroKey)
|
||||
.then(value => {
|
||||
if (canceled) return;
|
||||
if (value) {
|
||||
applyPreferenceOptions(JSON.parse(value) as OptimizerPreferenceOptions);
|
||||
} else {
|
||||
applyPreferenceOptions(defaultOptimizerPreferenceOptions());
|
||||
}
|
||||
preferenceLoadRef.current.skipNextSave = true;
|
||||
preferenceLoadRef.current.heroKey = selectedHeroKey;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Load optimizer preference error:', err);
|
||||
preferenceLoadRef.current.heroKey = selectedHeroKey;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!canceled) {
|
||||
window.setTimeout(() => {
|
||||
preferenceLoadRef.current.applying = false;
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [rememberPreferences, selectedHeroKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferenceLoadRef.current.initialized || !rememberPreferences || !selectedHeroKey || preferenceLoadRef.current.applying) {
|
||||
return;
|
||||
}
|
||||
if (preferenceLoadRef.current.skipNextSave) {
|
||||
preferenceLoadRef.current.skipNextSave = false;
|
||||
return;
|
||||
}
|
||||
if (preferenceSaveTimerRef.current !== null) {
|
||||
window.clearTimeout(preferenceSaveTimerRef.current);
|
||||
}
|
||||
preferenceSaveTimerRef.current = window.setTimeout(() => {
|
||||
saveCurrentPreference(selectedHeroKey).catch(err => {
|
||||
console.error('Save optimizer preference error:', err);
|
||||
});
|
||||
}, 400);
|
||||
}, [rememberPreferences, selectedHeroKey, setFilters, statFilters, mainStatFilters, weightValues, resultFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preferenceSaveTimerRef.current !== null) {
|
||||
window.clearTimeout(preferenceSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleResults.length === 0) {
|
||||
setSelectedResult(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedResult || !visibleResults.some(result => result.key === selectedResult.key)) {
|
||||
setSelectedResult(visibleResults[0]);
|
||||
}
|
||||
}, [visibleResults, selectedResult?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizePayload = <T,>(payload: T | T[]) => Array.isArray(payload) ? payload[0] : payload;
|
||||
const offProgress = EventsOn('optimize:progress', (payload?: {checked?: number; matched?: number; total?: number; percent?: number} | Array<{checked?: number; matched?: number; total?: number; percent?: number}>) => {
|
||||
const data = normalizePayload(payload || {});
|
||||
const checked = Number(data?.checked || 0);
|
||||
const matched = Number(data?.matched || 0);
|
||||
const total = Number(data?.total || 0);
|
||||
const rawPercent = Number(data?.percent || 0);
|
||||
const percent = Math.max(0, Math.min(100, Math.round(rawPercent * 10) / 10));
|
||||
setOptimizeProgress({checked, matched, total, percent});
|
||||
});
|
||||
const offDone = EventsOn('optimize:done', (payload?: BuildResult[] | any) => {
|
||||
const resp = normalizePayload(payload || {});
|
||||
const nextResults = ((resp?.results || []) as BuildResult[]).slice(0, MAX_RESULTS);
|
||||
setTotalCombos(resp?.totalCombos || 0);
|
||||
setResults(nextResults);
|
||||
setSelectedResult(nextResults[0] || null);
|
||||
setOptimizing(false);
|
||||
setOptimizeProgress(prev => ({...prev, matched: resp?.totalCombos || prev.matched, percent: 100}));
|
||||
if (nextResults.length > 0) {
|
||||
success(`配装完成,共 ${nextResults.length} 条结果`);
|
||||
} else {
|
||||
info('没有符合条件的配装结果');
|
||||
}
|
||||
});
|
||||
const offError = EventsOn('optimize:error', (payload?: string | string[]) => {
|
||||
const msg = String(normalizePayload(payload || '配装失败'));
|
||||
setOptimizing(false);
|
||||
error(msg);
|
||||
});
|
||||
const offCanceled = EventsOn('optimize:canceled', () => {
|
||||
setOptimizing(false);
|
||||
info('配装计算已中断');
|
||||
});
|
||||
return () => {
|
||||
offProgress();
|
||||
offDone();
|
||||
offError();
|
||||
offCanceled();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!leftPanelsRef.current) return;
|
||||
const observer = new ResizeObserver(entries => {
|
||||
@@ -425,22 +727,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
|
||||
const resetPreferences = () => {
|
||||
setWeightValues({
|
||||
atk: 3,
|
||||
def: 3,
|
||||
hp: 3,
|
||||
spd: 3,
|
||||
cr: 3,
|
||||
cd: 3,
|
||||
acc: 3,
|
||||
res: 3,
|
||||
atk: 0,
|
||||
def: 0,
|
||||
hp: 0,
|
||||
spd: 0,
|
||||
cr: 0,
|
||||
cd: 0,
|
||||
acc: 0,
|
||||
res: 0,
|
||||
});
|
||||
setResults([]);
|
||||
setSelectedResult(null);
|
||||
setTotalCombos(0);
|
||||
};
|
||||
|
||||
const buildNextSetFilters = (
|
||||
target: 'set1' | 'set2' | 'set3',
|
||||
nextValue: string[]
|
||||
): SetFilters => {
|
||||
if (target === 'set1') {
|
||||
return {set1: nextValue, set2: [], set3: []};
|
||||
}
|
||||
if (target === 'set2') {
|
||||
return {...setFilters, set2: nextValue, set3: []};
|
||||
}
|
||||
return {...setFilters, set3: nextValue};
|
||||
};
|
||||
|
||||
const applySetSelection = (nextFilters: SetFilters) => {
|
||||
if (!isValidSetFilterSelection(nextFilters)) {
|
||||
info('套装组合最多只能占用6件装备');
|
||||
return;
|
||||
}
|
||||
setSetFilters(nextFilters);
|
||||
setSetPickerOpen(false);
|
||||
};
|
||||
|
||||
const updateResultFilter = (key: StatKey, boundary: 'min' | 'max', value: number | null) => {
|
||||
setResultFilters(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...prev[key],
|
||||
[boundary]: value ?? undefined,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetResultFilters = () => {
|
||||
setResultFilters(createEmptyResultFilters());
|
||||
};
|
||||
|
||||
const updateRememberPreferences = async (checked: boolean) => {
|
||||
setRememberPreferences(checked);
|
||||
preferenceLoadRef.current.initialized = true;
|
||||
if (!checked && preferenceSaveTimerRef.current !== null) {
|
||||
window.clearTimeout(preferenceSaveTimerRef.current);
|
||||
preferenceSaveTimerRef.current = null;
|
||||
}
|
||||
if (checked && selectedHeroKey) {
|
||||
preferenceLoadRef.current.heroKey = selectedHeroKey;
|
||||
preferenceLoadRef.current.skipNextSave = false;
|
||||
}
|
||||
try {
|
||||
await App.SaveAppSetting('optimizer.rememberPreferences', checked ? 'true' : 'false');
|
||||
if (checked && selectedHeroKey) {
|
||||
await saveCurrentPreference(selectedHeroKey);
|
||||
preferenceLoadRef.current.heroKey = selectedHeroKey;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save optimizer remember setting error:', err);
|
||||
error('保存记录偏好设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const buildResults = async () => {
|
||||
console.log('OptimizerPage buildResults clicked');
|
||||
if (optimizing) {
|
||||
return;
|
||||
}
|
||||
if (!selectedHero) {
|
||||
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
|
||||
return;
|
||||
@@ -449,8 +813,18 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
|
||||
return;
|
||||
}
|
||||
if (getSetFilterPieceTotal(setFilters) === 0) {
|
||||
info('请先选择套装');
|
||||
return;
|
||||
}
|
||||
if (!STAT_LABELS.some(stat => weightValues[stat.key] > 0)) {
|
||||
info('请至少设置一个属性偏好');
|
||||
return;
|
||||
}
|
||||
setOptimizing(true);
|
||||
setOptimizeProgress({checked: 0, matched: 0, total: 0, percent: 0});
|
||||
try {
|
||||
console.log('OptimizeBuilds calling', {
|
||||
console.log('StartOptimizeBuilds calling', {
|
||||
heroId: String(selectedHero.id ?? ''),
|
||||
setFilters,
|
||||
statFilters,
|
||||
@@ -458,7 +832,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
weightValues,
|
||||
bonusStats: selectedHeroBonus,
|
||||
});
|
||||
const resp = await App.OptimizeBuilds({
|
||||
await App.StartOptimizeBuilds({
|
||||
heroId: String(selectedHero.code ?? ''),
|
||||
setFilters,
|
||||
statFilters,
|
||||
@@ -469,34 +843,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
maxCombos: MAX_COMBOS,
|
||||
maxResults: MAX_RESULTS,
|
||||
} as any);
|
||||
console.log('OptimizeBuilds returned', resp);
|
||||
const nextResults = (resp?.results || []) as BuildResult[];
|
||||
setTotalCombos(resp?.totalCombos || 0);
|
||||
setResults(nextResults);
|
||||
setSelectedResult(nextResults[0] || null);
|
||||
if (nextResults.length > 0) {
|
||||
success(`\u914d\u88c5\u5b8c\u6210\uff0c\u5171 ${nextResults.length} \u6761\u7ed3\u679c`);
|
||||
}
|
||||
if (nextResults.length === 0) {
|
||||
info('\u6ca1\u6709\u7b26\u5408\u6761\u4ef6\u7684\u914d\u88c5\u7ed3\u679c');
|
||||
}
|
||||
console.log('StartOptimizeBuilds started');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '\u914d\u88c5\u5931\u8d25';
|
||||
setOptimizing(false);
|
||||
if (msg.includes('中断') || msg.toLowerCase().includes('canceled') || msg.toLowerCase().includes('cancelled')) {
|
||||
info('配装计算已中断');
|
||||
} else {
|
||||
error(msg);
|
||||
console.error('OptimizeBuilds error:', err);
|
||||
}
|
||||
console.error('StartOptimizeBuilds error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOptimize = async () => {
|
||||
try {
|
||||
await App.CancelOptimizeBuilds();
|
||||
} catch (err) {
|
||||
console.error('CancelOptimizeBuilds error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const resultColumns = [
|
||||
{title: '套装', dataIndex: 'sets', key: 'sets', render: (v: string) => <Tag>{v}</Tag>},
|
||||
{title: '攻击', dataIndex: 'atk', key: 'atk'},
|
||||
{title: '防御', dataIndex: 'def', key: 'def'},
|
||||
{title: '生命', dataIndex: 'hp', key: 'hp'},
|
||||
{title: '速度', dataIndex: 'spd', key: 'spd'},
|
||||
{title: '暴击', dataIndex: 'cr', key: 'cr'},
|
||||
{title: '爆伤', dataIndex: 'cd', key: 'cd'},
|
||||
{title: '命中', dataIndex: 'acc', key: 'acc'},
|
||||
{title: '抵抗', dataIndex: 'res', key: 'res'},
|
||||
{
|
||||
title: '套装',
|
||||
dataIndex: 'sets',
|
||||
key: 'sets',
|
||||
width: 156,
|
||||
ellipsis: true,
|
||||
render: (v: string) => <Tag className="optimizer-result-set-tag">{v}</Tag>,
|
||||
},
|
||||
{title: '攻击', dataIndex: 'atk', key: 'atk', width: 92, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.atk - b.atk},
|
||||
{title: '防御', dataIndex: 'def', key: 'def', width: 92, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.def - b.def},
|
||||
{title: '生命', dataIndex: 'hp', key: 'hp', width: 96, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.hp - b.hp},
|
||||
{title: '速度', dataIndex: 'spd', key: 'spd', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.spd - b.spd},
|
||||
{title: '暴击', dataIndex: 'cr', key: 'cr', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.cr - b.cr},
|
||||
{title: '爆伤', dataIndex: 'cd', key: 'cd', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.cd - b.cd},
|
||||
{title: '命中', dataIndex: 'acc', key: 'acc', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.acc - b.acc},
|
||||
{title: '抵抗', dataIndex: 'res', key: 'res', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.res - b.res},
|
||||
];
|
||||
|
||||
const getItemSetKey = (item: RawItem) => item.set || (item.f ? INGAME_SET_MAP[item.f] : '');
|
||||
|
||||
const detailItemColumns = [
|
||||
{
|
||||
title: '部位',
|
||||
key: 'gear',
|
||||
width: 90,
|
||||
render: (_: unknown, item: RawItem) => GEAR_LABELS[item.gear || ''] || item.gear || '-',
|
||||
},
|
||||
{
|
||||
title: '套装类别',
|
||||
key: 'set',
|
||||
width: 140,
|
||||
render: (_: unknown, item: RawItem) => {
|
||||
const setKey = getItemSetKey(item);
|
||||
return setKey ? <Tag color="blue">{getSetLabel([setKey])}</Tag> : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '强化',
|
||||
key: 'enhance',
|
||||
width: 80,
|
||||
render: (_: unknown, item: RawItem) => item.enhance == null ? '-' : `+${item.enhance}`,
|
||||
},
|
||||
{
|
||||
title: '主属性',
|
||||
key: 'main',
|
||||
width: 170,
|
||||
render: (_: unknown, item: RawItem) => formatItemStat(item.main),
|
||||
},
|
||||
{
|
||||
title: '副属性',
|
||||
key: 'substats',
|
||||
render: (_: unknown, item: RawItem) => {
|
||||
if (!item.substats || item.substats.length === 0) return '-';
|
||||
return item.substats.map(stat => formatItemStat(stat)).join(' / ');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -528,7 +952,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col span={4}>
|
||||
<div style={{fontWeight: 500, marginBottom: 8}}>属性</div>
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
||||
{STAT_LABELS.map(stat => (
|
||||
@@ -574,7 +998,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
</div>
|
||||
</Col>
|
||||
<Col
|
||||
span={6}
|
||||
span={8}
|
||||
style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', gap: 10}}
|
||||
ref={rightPanelRef}
|
||||
>
|
||||
@@ -604,7 +1028,10 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set3)}</span>
|
||||
<Button
|
||||
className="optimizer-set-button"
|
||||
disabled={setFilters.set1.some(isFourPieceSet) || setFilters.set2.length === 0}
|
||||
disabled={
|
||||
setFilters.set2.length === 0 ||
|
||||
(setFilters.set3.length === 0 && getSetFilterPieceTotal(setFilters) >= 6)
|
||||
}
|
||||
onClick={(event) => openSetPicker('set3', event)}
|
||||
>
|
||||
{getSetLabel(setFilters.set3)}
|
||||
@@ -642,123 +1069,54 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
</div>
|
||||
</div>
|
||||
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">攻</span>
|
||||
<span className="optimizer-weight-label">攻击</span>
|
||||
<div className="optimizer-weight-header">
|
||||
<span>优先级</span>
|
||||
<span className="optimizer-weight-scale">0-5</span>
|
||||
</div>
|
||||
<div className="optimizer-weight-list">
|
||||
{WEIGHT_OPTIONS.map(option => (
|
||||
<div className="optimizer-weight-row" key={option.key}>
|
||||
<span className="optimizer-weight-icon">{option.icon}</span>
|
||||
<span className="optimizer-weight-label">{option.label}</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
tooltip={{open: false, formatter: null}}
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.atk}
|
||||
onChange={value => setWeightValues(prev => ({...prev, atk: value as number}))}
|
||||
value={weightValues[option.key]}
|
||||
onChange={value => {
|
||||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||
setWeightValues(prev => ({...prev, [option.key]: nextValue}));
|
||||
}}
|
||||
/>
|
||||
<span className="optimizer-weight-value">{weightValues[option.key]}</span>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">防</span>
|
||||
<span className="optimizer-weight-label">防御</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.def}
|
||||
onChange={value => setWeightValues(prev => ({...prev, def: value as number}))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap'}}>
|
||||
<Space size={6} align="center" style={{height: 32, flex: '0 0 auto', whiteSpace: 'nowrap'}}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={rememberPreferences}
|
||||
disabled={optimizing || loading}
|
||||
onChange={updateRememberPreferences}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">生</span>
|
||||
<span className="optimizer-weight-label">生命</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.hp}
|
||||
onChange={value => setWeightValues(prev => ({...prev, hp: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">速</span>
|
||||
<span className="optimizer-weight-label">速度</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.spd}
|
||||
onChange={value => setWeightValues(prev => ({...prev, spd: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">暴</span>
|
||||
<span className="optimizer-weight-label">暴击</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.cr}
|
||||
onChange={value => setWeightValues(prev => ({...prev, cr: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">伤</span>
|
||||
<span className="optimizer-weight-label">爆伤</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.cd}
|
||||
onChange={value => setWeightValues(prev => ({...prev, cd: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">命</span>
|
||||
<span className="optimizer-weight-label">命中</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.acc}
|
||||
onChange={value => setWeightValues(prev => ({...prev, acc: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="optimizer-weight-row">
|
||||
<span className="optimizer-weight-icon">抗</span>
|
||||
<span className="optimizer-weight-label">抵抗</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
dots
|
||||
className="optimizer-weight-slider"
|
||||
value={weightValues.res}
|
||||
onChange={value => setWeightValues(prev => ({...prev, res: value as number}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%'}}>
|
||||
<Button icon={<ReloadOutlined/>} onClick={loadLatestData} loading={loading} style={{height: 32}}>
|
||||
<Text style={{whiteSpace: 'nowrap'}}>记录偏好</Text>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined/>} onClick={loadLatestData} loading={loading} disabled={optimizing} style={{height: 32, display: 'none'}}>
|
||||
重新加载数据
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} style={{height: 32}}>
|
||||
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} disabled={optimizing} style={{height: 32}}>
|
||||
{'\u91cd\u7f6e\u5957\u88c5'}
|
||||
</Button>
|
||||
<Button icon={<FilterOutlined/>} onClick={resetPreferences} style={{height: 32}}>
|
||||
<Button icon={<FilterOutlined/>} onClick={resetPreferences} disabled={optimizing} style={{height: 32}}>
|
||||
{'\u91cd\u7f6e\u504f\u597d'}
|
||||
</Button>
|
||||
<Button type="primary" icon={<AppstoreOutlined/>} onClick={buildResults} style={{height: 32}}>
|
||||
<Button type="primary" icon={<AppstoreOutlined/>} onClick={buildResults} loading={optimizing} disabled={optimizing} style={{height: 32}}>
|
||||
{'\u5f00\u59cb\u914d\u88c5'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -767,24 +1125,61 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
</Card>
|
||||
|
||||
<Card title="配装结果" style={{marginBottom: 16}}>
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', marginBottom: 8}}>
|
||||
<div className="optimizer-result-tools">
|
||||
<div className="optimizer-result-panel">
|
||||
<div className="optimizer-result-tool-header">
|
||||
<div className="optimizer-result-tool-title">结果筛选</div>
|
||||
<Button size="small" onClick={resetResultFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="optimizer-result-filter-grid">
|
||||
{STAT_LABELS.map(stat => (
|
||||
<div className="optimizer-result-filter-row" key={stat.key}>
|
||||
<span>{stat.label}</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
placeholder="最小"
|
||||
value={resultFilters[stat.key].min}
|
||||
onChange={value => updateResultFilter(stat.key, 'min', typeof value === 'number' ? value : null)}
|
||||
/>
|
||||
<span className="optimizer-result-range-separator">-</span>
|
||||
<InputNumber
|
||||
size="small"
|
||||
placeholder="最大"
|
||||
value={resultFilters[stat.key].max}
|
||||
onChange={value => updateResultFilter(stat.key, 'max', typeof value === 'number' ? value : null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="optimizer-result-summary">
|
||||
<div>
|
||||
组合数量:<b>{totalCombos.toLocaleString()}</b> |
|
||||
结果数量:<b>{results.length.toLocaleString()}</b>
|
||||
结果数量:<b>{visibleResults.length.toLocaleString()}</b>
|
||||
{visibleResults.length !== results.length ? (
|
||||
<> / {results.length.toLocaleString()}</>
|
||||
) : null}
|
||||
</div>
|
||||
<Pagination
|
||||
size="small"
|
||||
total={results.length}
|
||||
total={visibleResults.length}
|
||||
pageSize={10}
|
||||
current={1}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={results}
|
||||
className="optimizer-result-table"
|
||||
dataSource={visibleResults}
|
||||
columns={resultColumns}
|
||||
pagination={{pageSize: 10}}
|
||||
scroll={{x: true}}
|
||||
scroll={{x: RESULT_TABLE_SCROLL_X}}
|
||||
showSorterTooltip={false}
|
||||
tableLayout="fixed"
|
||||
rowKey="key"
|
||||
onRow={record => ({
|
||||
onClick: () => setSelectedResult(record),
|
||||
@@ -794,6 +1189,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
|
||||
<Card title="配装详情">
|
||||
{selectedResult ? (
|
||||
<>
|
||||
<div style={{display: 'flex', gap: 24}}>
|
||||
<div>
|
||||
<Avatar size={48} icon={<UserOutlined/>}/>
|
||||
@@ -814,12 +1210,53 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
style={{marginTop: 16}}
|
||||
dataSource={orderItemsByGearSlot(selectedResult.items || [])}
|
||||
columns={detailItemColumns}
|
||||
pagination={false}
|
||||
rowKey={item => `${item.gear || 'gear'}-${item.id || ''}`}
|
||||
scroll={{x: true}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{color: '#999'}}>暂无配装结果</div>
|
||||
)}
|
||||
</Card>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Modal
|
||||
open={optimizing}
|
||||
title="正在计算配装"
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
centered
|
||||
footer={[
|
||||
<Button key="cancel" danger onClick={cancelOptimize}>
|
||||
中断计算
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="optimizer-progress-modal">
|
||||
<Progress
|
||||
className="optimizer-progress-bar"
|
||||
percent={optimizeProgress.percent}
|
||||
status="active"
|
||||
showInfo={false}
|
||||
strokeColor={{from: '#1677ff', to: '#52c41a'}}
|
||||
/>
|
||||
<div className="optimizer-progress-meta">
|
||||
<span className="optimizer-progress-text">
|
||||
已计算 <b>{optimizeProgress.checked.toLocaleString()}</b>
|
||||
{' '}个组合,符合过滤 <b>{optimizeProgress.matched.toLocaleString()}</b>
|
||||
{' '}个组合
|
||||
</span>
|
||||
<span className="optimizer-progress-percent">{optimizeProgress.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={bonusEditorOpen}
|
||||
title={'\u989d\u5916\u52a0\u6210'}
|
||||
@@ -907,28 +1344,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
||||
className="optimizer-set-modal-item"
|
||||
onClick={() => {
|
||||
const nextValue = option.setKey ? [option.setKey] : [];
|
||||
if (setPickerTarget === 'set1') {
|
||||
const hasFour = nextValue.some(isFourPieceSet);
|
||||
const hasTwo = nextValue.some(isTwoPieceSet);
|
||||
if (hasFour && hasTwo) {
|
||||
info('第一组只能选择纯4件套或纯2件套');
|
||||
return;
|
||||
}
|
||||
setSetFilters({set1: nextValue, set2: [], set3: []});
|
||||
} else if (setPickerTarget === 'set2') {
|
||||
if (nextValue.some(isFourPieceSet)) {
|
||||
info('第二组仅支持2件套');
|
||||
return;
|
||||
}
|
||||
setSetFilters(prev => ({...prev, set2: nextValue, set3: []}));
|
||||
} else {
|
||||
if (nextValue.some(isFourPieceSet)) {
|
||||
info('第三组仅支持2件套');
|
||||
return;
|
||||
}
|
||||
setSetFilters(prev => ({...prev, set3: nextValue}));
|
||||
}
|
||||
setSetPickerOpen(false);
|
||||
applySetSelection(buildNextSetFilters(setPickerTarget, nextValue));
|
||||
}}
|
||||
>
|
||||
<span className="optimizer-set-modal-icon">◎</span>
|
||||
|
||||
@@ -186,19 +186,6 @@
|
||||
color: #9fb3e5;
|
||||
}
|
||||
|
||||
.optimizer-weight-row {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.optimizer-weight-label {
|
||||
color: #c9d7f7;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.optimizer-left-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -206,83 +193,139 @@
|
||||
}
|
||||
|
||||
.optimizer-side-panel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(124px, 0.82fr) minmax(176px, 1.18fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.optimizer-left-panels {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-panel {
|
||||
background: linear-gradient(180deg, #1f2c49 0%, #1a233b 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #18243c 0%, #11192d 100%);
|
||||
border: 1px solid rgba(145, 174, 226, 0.18);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
align-self: flex-start;
|
||||
height: fit-content;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.optimizer-weight-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 34px 1fr;
|
||||
.optimizer-weight-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(6, 10, 20, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
justify-content: space-between;
|
||||
height: 20px;
|
||||
color: #e4ecff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.optimizer-weight-row-no-label {
|
||||
grid-template-columns: 20px 1fr;
|
||||
}
|
||||
|
||||
.optimizer-weight-row-no-label .optimizer-weight-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.optimizer-weight-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-row .ant-slider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-icon {
|
||||
width: 18px;
|
||||
.optimizer-weight-scale {
|
||||
min-width: 36px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #c9d7f7;
|
||||
border-radius: 999px;
|
||||
background: rgba(123, 180, 255, 0.14);
|
||||
color: #9fc4ff;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.optimizer-weight-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 34px minmax(72px, 1fr) 26px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
line-height: 1;
|
||||
background: rgba(7, 12, 24, 0.62);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
transition: border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.optimizer-weight-row:hover {
|
||||
background: rgba(10, 18, 34, 0.82);
|
||||
border-color: rgba(123, 180, 255, 0.28);
|
||||
}
|
||||
|
||||
.optimizer-weight-row .ant-slider {
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.optimizer-weight-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, rgba(123, 180, 255, 0.26), rgba(123, 180, 255, 0.1));
|
||||
color: #dbe8ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.optimizer-weight-label {
|
||||
color: #c9d7f7;
|
||||
color: #d2def7;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.optimizer-weight-slider {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.optimizer-weight-value {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.optimizer-weight-slider .ant-slider-rail {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.optimizer-weight-slider .ant-slider-track {
|
||||
background-color: #7bb4ff;
|
||||
background-color: #83bcff;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
@@ -290,7 +333,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -1px;
|
||||
border-color: #7bb4ff;
|
||||
border-color: #83bcff;
|
||||
box-shadow: none;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
@@ -299,15 +342,151 @@
|
||||
.optimizer-weight-slider .ant-slider-handle::after {
|
||||
display: none;
|
||||
}
|
||||
.optimizer-weight-row .ant-slider-rail {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.optimizer-progress-modal {
|
||||
padding: 6px 2px 2px;
|
||||
}
|
||||
|
||||
.optimizer-weight-row .ant-slider-track {
|
||||
background-color: #6aa9ff;
|
||||
.optimizer-progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.optimizer-weight-row .ant-slider-handle {
|
||||
border-color: #6aa9ff;
|
||||
background-color: #ffffff;
|
||||
.optimizer-progress-bar .ant-progress-outer {
|
||||
width: 100%;
|
||||
padding-inline-end: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.optimizer-progress-bar .ant-progress-inner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.optimizer-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
min-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optimizer-progress-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.optimizer-progress-text b {
|
||||
display: inline-block;
|
||||
min-width: 86px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.optimizer-progress-percent {
|
||||
flex: 0 0 54px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.optimizer-result-tools {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.optimizer-result-panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.optimizer-result-tool-title {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.optimizer-result-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.optimizer-result-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 8px 10px;
|
||||
}
|
||||
|
||||
.optimizer-result-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 10px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.optimizer-result-filter-row .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.optimizer-result-range-separator {
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.optimizer-result-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.optimizer-result-table .ant-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.optimizer-result-table .ant-table-thead > tr > th,
|
||||
.optimizer-result-table .ant-table-tbody > tr > td {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optimizer-result-table .ant-table-column-sorters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 16px;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optimizer-result-table .ant-table-column-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.optimizer-result-table .ant-table-column-sorter {
|
||||
width: 16px;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.optimizer-result-set-tag {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.optimizer-result-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
429
frontend/src/utils/optimizer.ts
Normal file
429
frontend/src/utils/optimizer.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
export type RawItem = {
|
||||
id?: string | number;
|
||||
level?: number;
|
||||
enhance?: number;
|
||||
rank?: string;
|
||||
gear?: string;
|
||||
set?: string;
|
||||
f?: string;
|
||||
main?: { type?: string; value?: number };
|
||||
substats?: Array<{ type?: string; value?: number }>;
|
||||
};
|
||||
|
||||
export type RawHero = {
|
||||
id?: string | number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
atk?: number;
|
||||
def?: number;
|
||||
hp?: number;
|
||||
spd?: number;
|
||||
cr?: number;
|
||||
cd?: number;
|
||||
eff?: number;
|
||||
res?: number;
|
||||
baseAtk?: number;
|
||||
baseDef?: number;
|
||||
baseHp?: number;
|
||||
baseSpd?: number;
|
||||
baseCr?: number;
|
||||
baseCd?: number;
|
||||
baseEff?: number;
|
||||
baseRes?: number;
|
||||
};
|
||||
|
||||
export type StatKey = 'atk' | 'def' | 'hp' | 'spd' | 'cr' | 'cd' | 'acc' | 'res';
|
||||
export type MainStatKey =
|
||||
| 'Attack'
|
||||
| 'Defense'
|
||||
| 'Health'
|
||||
| 'AttackPercent'
|
||||
| 'DefensePercent'
|
||||
| 'HealthPercent'
|
||||
| 'CriticalHitChancePercent'
|
||||
| 'CriticalHitDamagePercent'
|
||||
| 'EffectivenessPercent'
|
||||
| 'EffectResistancePercent'
|
||||
| 'Speed';
|
||||
export type StatRange = { min?: number; max?: number };
|
||||
export type Filters = Record<StatKey, StatRange>;
|
||||
|
||||
export type ItemStats = {
|
||||
atk: number;
|
||||
def: number;
|
||||
hp: number;
|
||||
spd: number;
|
||||
cr: number;
|
||||
cd: number;
|
||||
acc: number;
|
||||
res: number;
|
||||
atkPct: number;
|
||||
defPct: number;
|
||||
hpPct: number;
|
||||
};
|
||||
|
||||
export type BonusStats = {
|
||||
atk: number;
|
||||
def: number;
|
||||
hp: number;
|
||||
atkPct: number;
|
||||
defPct: number;
|
||||
hpPct: number;
|
||||
spd: number;
|
||||
cr: number;
|
||||
cd: number;
|
||||
eff: number;
|
||||
res: number;
|
||||
finalAtkMultiplier: number;
|
||||
finalDefMultiplier: number;
|
||||
finalHpMultiplier: number;
|
||||
};
|
||||
|
||||
export type SetFilters = {
|
||||
set1: string[];
|
||||
set2: string[];
|
||||
set3: string[];
|
||||
};
|
||||
|
||||
export const emptyFilters: Filters = {
|
||||
atk: {},
|
||||
def: {},
|
||||
hp: {},
|
||||
spd: {},
|
||||
cr: {},
|
||||
cd: {},
|
||||
acc: {},
|
||||
res: {},
|
||||
};
|
||||
|
||||
export const emptySetFilters: SetFilters = {
|
||||
set1: [],
|
||||
set2: [],
|
||||
set3: [],
|
||||
};
|
||||
|
||||
export const emptyBonusStats: BonusStats = {
|
||||
atk: 0,
|
||||
def: 0,
|
||||
hp: 0,
|
||||
atkPct: 0,
|
||||
defPct: 0,
|
||||
hpPct: 0,
|
||||
spd: 0,
|
||||
cr: 0,
|
||||
cd: 0,
|
||||
eff: 0,
|
||||
res: 0,
|
||||
finalAtkMultiplier: 0,
|
||||
finalDefMultiplier: 0,
|
||||
finalHpMultiplier: 0,
|
||||
};
|
||||
|
||||
export const FOUR_PIECE_SETS = new Set([
|
||||
'AttackSet',
|
||||
'SpeedSet',
|
||||
'DestructionSet',
|
||||
'LifestealSet',
|
||||
'ProtectionSet',
|
||||
'CounterSet',
|
||||
'RageSet',
|
||||
'RevengeSet',
|
||||
'InjurySet',
|
||||
'ReversalSet',
|
||||
'RiposteSet',
|
||||
'WarfareSet',
|
||||
]);
|
||||
|
||||
export const TWO_PIECE_SETS = new Set([
|
||||
'HealthSet',
|
||||
'DefenseSet',
|
||||
'CriticalSet',
|
||||
'HitSet',
|
||||
'ResistSet',
|
||||
'UnitySet',
|
||||
'ImmunitySet',
|
||||
'PenetrationSet',
|
||||
'TorrentSet',
|
||||
'PursuitSet',
|
||||
]);
|
||||
|
||||
export const toNumber = (value: any) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
return value;
|
||||
};
|
||||
|
||||
export const buildItemStats = (item: RawItem): ItemStats => {
|
||||
const stats: ItemStats = {
|
||||
atk: 0,
|
||||
def: 0,
|
||||
hp: 0,
|
||||
spd: 0,
|
||||
cr: 0,
|
||||
cd: 0,
|
||||
acc: 0,
|
||||
res: 0,
|
||||
atkPct: 0,
|
||||
defPct: 0,
|
||||
hpPct: 0,
|
||||
};
|
||||
|
||||
const applyStat = (type?: string, value?: number) => {
|
||||
if (!type) return;
|
||||
const v = toNumber(value);
|
||||
switch (type) {
|
||||
case 'Attack':
|
||||
stats.atk += v;
|
||||
break;
|
||||
case 'Defense':
|
||||
stats.def += v;
|
||||
break;
|
||||
case 'Health':
|
||||
stats.hp += v;
|
||||
break;
|
||||
case 'Speed':
|
||||
stats.spd += v;
|
||||
break;
|
||||
case 'CriticalHitChancePercent':
|
||||
stats.cr += v;
|
||||
break;
|
||||
case 'CriticalHitDamagePercent':
|
||||
stats.cd += v;
|
||||
break;
|
||||
case 'EffectivenessPercent':
|
||||
stats.acc += v;
|
||||
break;
|
||||
case 'EffectResistancePercent':
|
||||
stats.res += v;
|
||||
break;
|
||||
case 'AttackPercent':
|
||||
stats.atkPct += v;
|
||||
break;
|
||||
case 'DefensePercent':
|
||||
stats.defPct += v;
|
||||
break;
|
||||
case 'HealthPercent':
|
||||
stats.hpPct += v;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (item.main) {
|
||||
applyStat(item.main.type, item.main.value);
|
||||
}
|
||||
if (Array.isArray(item.substats)) {
|
||||
item.substats.forEach(sub => applyStat(sub.type, sub.value));
|
||||
}
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export const buildBaseStats = (hero?: RawHero) => {
|
||||
return {
|
||||
atk: toNumber(hero?.baseAtk ?? hero?.atk),
|
||||
def: toNumber(hero?.baseDef ?? hero?.def),
|
||||
hp: toNumber(hero?.baseHp ?? hero?.hp),
|
||||
spd: toNumber(hero?.baseSpd ?? hero?.spd),
|
||||
cr: toNumber(hero?.baseCr ?? hero?.cr),
|
||||
cd: toNumber(hero?.baseCd ?? hero?.cd),
|
||||
acc: toNumber(hero?.baseEff ?? hero?.eff),
|
||||
res: toNumber(hero?.baseRes ?? hero?.res),
|
||||
};
|
||||
};
|
||||
|
||||
export const scoreStats = (stats: ItemStats, baseStats: ReturnType<typeof buildBaseStats>) => {
|
||||
const atk = stats.atk + baseStats.atk * (stats.atkPct / 100);
|
||||
const def = stats.def + baseStats.def * (stats.defPct / 100);
|
||||
const hp = stats.hp + baseStats.hp * (stats.hpPct / 100);
|
||||
|
||||
return (
|
||||
stats.spd * 2 +
|
||||
stats.cr +
|
||||
stats.cd +
|
||||
stats.acc +
|
||||
stats.res +
|
||||
(atk + def + hp) / 100
|
||||
);
|
||||
};
|
||||
|
||||
export const buildTotalStats = (
|
||||
statsList: ItemStats[],
|
||||
baseStats: ReturnType<typeof buildBaseStats>,
|
||||
bonusStats: BonusStats,
|
||||
items: RawItem[]
|
||||
) => {
|
||||
const totals: ItemStats = {
|
||||
atk: 0,
|
||||
def: 0,
|
||||
hp: 0,
|
||||
spd: 0,
|
||||
cr: 0,
|
||||
cd: 0,
|
||||
acc: 0,
|
||||
res: 0,
|
||||
atkPct: 0,
|
||||
defPct: 0,
|
||||
hpPct: 0,
|
||||
};
|
||||
|
||||
statsList.forEach(stats => {
|
||||
totals.atk += stats.atk;
|
||||
totals.def += stats.def;
|
||||
totals.hp += stats.hp;
|
||||
totals.spd += stats.spd;
|
||||
totals.cr += stats.cr;
|
||||
totals.cd += stats.cd;
|
||||
totals.acc += stats.acc;
|
||||
totals.res += stats.res;
|
||||
totals.atkPct += stats.atkPct;
|
||||
totals.defPct += stats.defPct;
|
||||
totals.hpPct += stats.hpPct;
|
||||
});
|
||||
|
||||
const bonusBaseAtk = baseStats.atk + baseStats.atk * (bonusStats.atkPct / 100) + bonusStats.atk;
|
||||
const bonusBaseDef = baseStats.def + baseStats.def * (bonusStats.defPct / 100) + bonusStats.def;
|
||||
const bonusBaseHp = baseStats.hp + baseStats.hp * (bonusStats.hpPct / 100) + bonusStats.hp;
|
||||
|
||||
const bonusMaxAtk = 1 + bonusStats.finalAtkMultiplier / 100;
|
||||
const bonusMaxDef = 1 + bonusStats.finalDefMultiplier / 100;
|
||||
const bonusMaxHp = 1 + bonusStats.finalHpMultiplier / 100;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
items.forEach(item => {
|
||||
if (!item.set) return;
|
||||
counts.set(item.set, (counts.get(item.set) || 0) + 1);
|
||||
});
|
||||
|
||||
const attackSetBonus = (counts.get('AttackSet') || 0) >= 4 ? 0.45 * baseStats.atk : 0;
|
||||
const healthSetBonus = Math.floor((counts.get('HealthSet') || 0) / 2) * 0.20 * baseStats.hp;
|
||||
const defenseSetBonus = Math.floor((counts.get('DefenseSet') || 0) / 2) * 0.20 * baseStats.def;
|
||||
const speedSetBonus = (counts.get('SpeedSet') || 0) >= 4 ? 0.25 * baseStats.spd : 0;
|
||||
const revengeSetBonus = (counts.get('RevengeSet') || 0) >= 4 ? 0.12 * baseStats.spd : 0;
|
||||
const reversalSetBonus = (counts.get('ReversalSet') || 0) >= 4 ? 0.15 * baseStats.spd : 0;
|
||||
const criticalSetBonus = Math.floor((counts.get('CriticalSet') || 0) / 2) * 12;
|
||||
const hitSetBonus = Math.floor((counts.get('HitSet') || 0) / 2) * 20;
|
||||
const resistSetBonus = Math.floor((counts.get('ResistSet') || 0) / 2) * 20;
|
||||
const destructionSetBonus = (counts.get('DestructionSet') || 0) >= 4 ? 60 : 0;
|
||||
const warfareSetBonus = (counts.get('WarfareSet') || 0) >= 4 ? 0.20 * baseStats.hp : 0;
|
||||
const torrentSetPenalty = Math.floor((counts.get('TorrentSet') || 0) / 2) * (-0.10 * baseStats.hp);
|
||||
|
||||
const atkFlat = totals.atk + baseStats.atk * (totals.atkPct / 100);
|
||||
const defFlat = totals.def + baseStats.def * (totals.defPct / 100);
|
||||
const hpFlat = totals.hp + baseStats.hp * (totals.hpPct / 100);
|
||||
|
||||
const atk = (bonusBaseAtk + atkFlat + attackSetBonus) * bonusMaxAtk;
|
||||
const def = (bonusBaseDef + defFlat + defenseSetBonus) * bonusMaxDef;
|
||||
const hp = (bonusBaseHp + hpFlat + healthSetBonus + warfareSetBonus + torrentSetPenalty) * bonusMaxHp;
|
||||
const spd = baseStats.spd + totals.spd + speedSetBonus + revengeSetBonus + reversalSetBonus + bonusStats.spd;
|
||||
const cr = Math.min(100, baseStats.cr + totals.cr + criticalSetBonus + bonusStats.cr);
|
||||
const cd = Math.min(350, baseStats.cd + totals.cd + destructionSetBonus + bonusStats.cd);
|
||||
const acc = baseStats.acc + totals.acc + hitSetBonus + bonusStats.eff;
|
||||
const res = baseStats.res + totals.res + resistSetBonus + bonusStats.res;
|
||||
|
||||
return {atk, def, hp, spd, cr, cd, acc, res};
|
||||
};
|
||||
|
||||
export const passesFilters = (totals: ReturnType<typeof buildTotalStats>, filters: Filters) => {
|
||||
return Object.keys(filters).every(key => {
|
||||
const statKey = key as StatKey;
|
||||
const value = (totals as any)[statKey];
|
||||
const range = filters[statKey];
|
||||
if (range.min !== undefined && value < range.min) return false;
|
||||
if (range.max !== undefined && value > range.max) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const isFourPieceSet = (setName: string) => FOUR_PIECE_SETS.has(setName);
|
||||
export const isTwoPieceSet = (setName: string) => TWO_PIECE_SETS.has(setName);
|
||||
|
||||
export const getSetPieceCount = (setName: string) => {
|
||||
if (!setName || setName === 'All') return 0;
|
||||
if (isFourPieceSet(setName)) return 4;
|
||||
if (isTwoPieceSet(setName)) return 2;
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const getSelectedSetNames = (setFilters: SetFilters) => {
|
||||
return [setFilters.set1, setFilters.set2, setFilters.set3]
|
||||
.flat()
|
||||
.filter(setName => setName && setName !== 'All');
|
||||
};
|
||||
|
||||
export const getSetFilterPieceTotal = (setFilters: SetFilters) => {
|
||||
return getSelectedSetNames(setFilters).reduce((total, setName) => {
|
||||
const pieces = getSetPieceCount(setName);
|
||||
return pieces > 0 ? total + pieces : total;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const isValidSetFilterSelection = (setFilters: SetFilters) => {
|
||||
const selectedSets = getSelectedSetNames(setFilters);
|
||||
if (selectedSets.length === 0) return true;
|
||||
|
||||
const totalPieces = selectedSets.reduce((total, setName) => {
|
||||
const pieces = getSetPieceCount(setName);
|
||||
if (pieces <= 0) return Number.POSITIVE_INFINITY;
|
||||
return total + pieces;
|
||||
}, 0);
|
||||
|
||||
return totalPieces <= 6;
|
||||
};
|
||||
|
||||
const buildRequiredSetCounts = (setFilters: SetFilters) => {
|
||||
const required = new Map<string, number>();
|
||||
for (const setName of getSelectedSetNames(setFilters)) {
|
||||
const pieces = getSetPieceCount(setName);
|
||||
if (pieces <= 0) return null;
|
||||
required.set(setName, (required.get(setName) || 0) + pieces);
|
||||
}
|
||||
return required;
|
||||
};
|
||||
|
||||
export const getCompletedSets = (items: RawItem[]) => {
|
||||
const counts = new Map<string, number>();
|
||||
items.forEach(item => {
|
||||
if (!item.set) return;
|
||||
counts.set(item.set, (counts.get(item.set) || 0) + 1);
|
||||
});
|
||||
|
||||
const completed: string[] = [];
|
||||
counts.forEach((count, setName) => {
|
||||
if (FOUR_PIECE_SETS.has(setName) && count >= 4) {
|
||||
completed.push(setName);
|
||||
return;
|
||||
}
|
||||
if (TWO_PIECE_SETS.has(setName) && count >= 2) {
|
||||
const stacks = Math.floor(count / 2);
|
||||
for (let i = 0; i < stacks; i += 1) {
|
||||
completed.push(setName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return completed;
|
||||
};
|
||||
|
||||
export const passesSetFilter = (items: RawItem[], setFilters: SetFilters) => {
|
||||
const requiredCounts = buildRequiredSetCounts(setFilters);
|
||||
if (!requiredCounts) return false;
|
||||
if (requiredCounts.size === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!isValidSetFilterSelection(setFilters)) return false;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
items.forEach(item => {
|
||||
if (!item.set) return;
|
||||
counts.set(item.set, (counts.get(item.set) || 0) + 1);
|
||||
});
|
||||
|
||||
for (const [setName, requiredPieces] of requiredCounts) {
|
||||
if ((counts.get(setName) || 0) < requiredPieces) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
8
frontend/wailsjs/go/service/App.d.ts
vendored
8
frontend/wailsjs/go/service/App.d.ts
vendored
@@ -2,6 +2,8 @@
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {model} from '../models';
|
||||
|
||||
export function CancelOptimizeBuilds():Promise<void>;
|
||||
|
||||
export function DeleteParsedSession(arg1:number):Promise<void>;
|
||||
|
||||
export function ExportCurrentData(arg1:string):Promise<void>;
|
||||
@@ -24,6 +26,8 @@ export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
|
||||
|
||||
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
|
||||
|
||||
export function GetOptimizerPreference(arg1:string):Promise<string>;
|
||||
|
||||
export function GetParsedDataByID(arg1:number):Promise<model.ParsedResult>;
|
||||
|
||||
export function GetParsedSessions():Promise<Array<model.ParsedSession>>;
|
||||
@@ -38,12 +42,16 @@ export function ReadRawJsonFile():Promise<model.ParsedResult>;
|
||||
|
||||
export function SaveAppSetting(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SaveOptimizerPreference(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||
|
||||
export function StartCapture(arg1:string):Promise<void>;
|
||||
|
||||
export function StartCaptureWithFilter(arg1:string,arg2:string):Promise<void>;
|
||||
|
||||
export function StartOptimizeBuilds(arg1:model.OptimizeRequest):Promise<void>;
|
||||
|
||||
export function StopAndParseCapture():Promise<model.ParsedResult>;
|
||||
|
||||
export function StopCapture():Promise<void>;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function CancelOptimizeBuilds() {
|
||||
return window['go']['service']['App']['CancelOptimizeBuilds']();
|
||||
}
|
||||
|
||||
export function DeleteParsedSession(arg1) {
|
||||
return window['go']['service']['App']['DeleteParsedSession'](arg1);
|
||||
}
|
||||
@@ -46,6 +50,10 @@ export function GetNetworkInterfaces() {
|
||||
return window['go']['service']['App']['GetNetworkInterfaces']();
|
||||
}
|
||||
|
||||
export function GetOptimizerPreference(arg1) {
|
||||
return window['go']['service']['App']['GetOptimizerPreference'](arg1);
|
||||
}
|
||||
|
||||
export function GetParsedDataByID(arg1) {
|
||||
return window['go']['service']['App']['GetParsedDataByID'](arg1);
|
||||
}
|
||||
@@ -74,6 +82,10 @@ export function SaveAppSetting(arg1, arg2) {
|
||||
return window['go']['service']['App']['SaveAppSetting'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SaveOptimizerPreference(arg1, arg2) {
|
||||
return window['go']['service']['App']['SaveOptimizerPreference'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SaveParsedDataToDatabase(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['service']['App']['SaveParsedDataToDatabase'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -86,6 +98,10 @@ export function StartCaptureWithFilter(arg1, arg2) {
|
||||
return window['go']['service']['App']['StartCaptureWithFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function StartOptimizeBuilds(arg1) {
|
||||
return window['go']['service']['App']['StartOptimizeBuilds'](arg1);
|
||||
}
|
||||
|
||||
export function StopAndParseCapture() {
|
||||
return window['go']['service']['App']['StopAndParseCapture']();
|
||||
}
|
||||
|
||||
3
go.mod
3
go.mod
@@ -6,7 +6,7 @@ toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/wailsapp/wails/v2 v2.10.1
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
go.uber.org/zap v1.26.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -14,6 +14,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
@@ -67,8 +69,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns=
|
||||
github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
|
||||
@@ -87,9 +87,17 @@ func (d *Database) initTables() error {
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
optimizerPreferencesTable := `
|
||||
CREATE TABLE IF NOT EXISTS optimizer_preferences (
|
||||
hero_id TEXT PRIMARY KEY,
|
||||
options_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
tables := []string{
|
||||
parsedDataTable,
|
||||
settingsTable,
|
||||
optimizerPreferencesTable,
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
@@ -221,6 +229,9 @@ func (d *Database) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := d.db.QueryRow(stmt, key).Scan(&value)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
@@ -246,3 +257,26 @@ func (d *Database) GetAllSettings() (map[string]string, error) {
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveOptimizerPreference saves the latest optimizer options for a hero.
|
||||
func (d *Database) SaveOptimizerPreference(heroID string, optionsJSON string) error {
|
||||
stmt := `
|
||||
INSERT OR REPLACE INTO optimizer_preferences (hero_id, options_json, updated_at)
|
||||
VALUES (?, ?, ?)`
|
||||
_, err := d.db.Exec(stmt, heroID, optionsJSON, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOptimizerPreference returns saved optimizer options for a hero.
|
||||
func (d *Database) GetOptimizerPreference(heroID string) (string, error) {
|
||||
stmt := "SELECT options_json FROM optimizer_preferences WHERE hero_id = ?"
|
||||
var optionsJSON string
|
||||
err := d.db.QueryRow(stmt, heroID).Scan(&optionsJSON)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return optionsJSON, nil
|
||||
}
|
||||
|
||||
2115
internal/optimizer/optimizer.go
Normal file
2115
internal/optimizer/optimizer.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -116,3 +116,22 @@ func (s *DatabaseService) GetAllAppSettings() (map[string]string, error) {
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveOptimizerPreference saves the latest optimizer options for a hero.
|
||||
func (s *DatabaseService) SaveOptimizerPreference(heroID string, optionsJSON string) error {
|
||||
if err := s.db.SaveOptimizerPreference(heroID, optionsJSON); err != nil {
|
||||
s.logger.Error("保存配装偏好失败", "error", err, "hero_id", heroID)
|
||||
return fmt.Errorf("保存配装偏好失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOptimizerPreference gets saved optimizer options for a hero.
|
||||
func (s *DatabaseService) GetOptimizerPreference(heroID string) (string, error) {
|
||||
optionsJSON, err := s.db.GetOptimizerPreference(heroID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取配装偏好失败", "error", err, "hero_id", heroID)
|
||||
return "", fmt.Errorf("获取配装偏好失败: %w", err)
|
||||
}
|
||||
return optionsJSON, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"equipment-analyzer/internal/capture"
|
||||
@@ -23,6 +25,8 @@ type App struct {
|
||||
parserService *ParserService
|
||||
database *model.Database
|
||||
databaseService *DatabaseService
|
||||
optimizeMu sync.Mutex
|
||||
optimizeCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewApp(cfg *config.Config, logger *utils.Logger) *App {
|
||||
@@ -84,16 +88,97 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// OptimizeBuilds runs the optimizer on latest parsed data.
|
||||
// OptimizeBuilds runs the optimizer on latest parsed data and returns when done.
|
||||
func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse, error) {
|
||||
log.Printf("[service] OptimizeBuilds entry heroId=%s", req.HeroID)
|
||||
log.Printf("[service] OptimizeBuilds sync entry heroId=%s", req.HeroID)
|
||||
optCtx, cancel := context.WithCancel(context.Background())
|
||||
a.optimizeMu.Lock()
|
||||
if a.optimizeCancel != nil {
|
||||
a.optimizeMu.Unlock()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("已有配装计算正在进行")
|
||||
}
|
||||
a.optimizeCancel = cancel
|
||||
a.optimizeMu.Unlock()
|
||||
defer func() {
|
||||
cancel()
|
||||
a.optimizeMu.Lock()
|
||||
a.optimizeCancel = nil
|
||||
a.optimizeMu.Unlock()
|
||||
}()
|
||||
|
||||
resp, err := a.runOptimizeBuilds(optCtx, req, a.emitOptimizeProgress)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Printf("[service] OptimizeBuilds canceled")
|
||||
return nil, fmt.Errorf("配装计算已中断")
|
||||
}
|
||||
log.Printf("[service] OptimizeBuilds optimize failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// StartOptimizeBuilds starts the optimizer in the background and reports progress through Wails events.
|
||||
func (a *App) StartOptimizeBuilds(req model.OptimizeRequest) error {
|
||||
log.Printf("[service] StartOptimizeBuilds entry heroId=%s", req.HeroID)
|
||||
optCtx, cancel := context.WithCancel(context.Background())
|
||||
a.optimizeMu.Lock()
|
||||
if a.optimizeCancel != nil {
|
||||
a.optimizeMu.Unlock()
|
||||
cancel()
|
||||
return fmt.Errorf("已有配装计算正在进行")
|
||||
}
|
||||
a.optimizeCancel = cancel
|
||||
a.optimizeMu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
cancel()
|
||||
a.optimizeMu.Lock()
|
||||
a.optimizeCancel = nil
|
||||
a.optimizeMu.Unlock()
|
||||
}()
|
||||
|
||||
resp, err := a.runOptimizeBuilds(optCtx, req, a.emitOptimizeProgress)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Printf("[service] StartOptimizeBuilds canceled")
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "optimize:canceled", "配装计算已中断")
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[service] StartOptimizeBuilds optimize failed: %v", err)
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "optimize:error", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[service] StartOptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "optimize:done", resp)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) runOptimizeBuilds(ctx context.Context, req model.OptimizeRequest, progress optimizer.ProgressCallback) (*model.OptimizeResponse, error) {
|
||||
parsedResult, err := a.GetLatestParsedDataFromDatabase()
|
||||
if err != nil {
|
||||
log.Printf("[service] OptimizeBuilds get data failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
|
||||
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", len(parsedResult.Items), len(parsedResult.Heroes))
|
||||
itemCount := 0
|
||||
heroCount := 0
|
||||
if parsedResult != nil {
|
||||
itemCount = len(parsedResult.Items)
|
||||
heroCount = len(parsedResult.Heroes)
|
||||
}
|
||||
if parsedResult == nil || (itemCount == 0 && heroCount == 0) {
|
||||
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", itemCount, heroCount)
|
||||
return &model.OptimizeResponse{TotalCombos: 0, Results: []model.OptimizeResult{}}, nil
|
||||
}
|
||||
|
||||
@@ -140,13 +225,37 @@ func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse
|
||||
heroes = templateHeroes
|
||||
|
||||
log.Printf("[service] OptimizeBuilds parsed items=%d heroes=%d", len(items), len(heroes))
|
||||
resp, err := optimizer.Optimize(items, heroes, req)
|
||||
if err != nil {
|
||||
log.Printf("[service] OptimizeBuilds optimize failed: %v", err)
|
||||
return nil, err
|
||||
return optimizer.OptimizeWithContext(ctx, items, heroes, req, progress)
|
||||
}
|
||||
|
||||
func (a *App) emitOptimizeProgress(checked int, total int, matched int) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
|
||||
return resp, nil
|
||||
percent := 0.0
|
||||
if total > 0 {
|
||||
percent = float64(checked) * 100 / float64(total)
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "optimize:progress", map[string]interface{}{
|
||||
"checked": checked,
|
||||
"matched": matched,
|
||||
"total": total,
|
||||
"percent": percent,
|
||||
})
|
||||
}
|
||||
|
||||
// CancelOptimizeBuilds stops the currently running optimizer, if any.
|
||||
func (a *App) CancelOptimizeBuilds() error {
|
||||
a.optimizeMu.Lock()
|
||||
cancel := a.optimizeCancel
|
||||
a.optimizeMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkInterfaces returns available network interfaces.
|
||||
@@ -529,6 +638,28 @@ func (a *App) GetAllAppSettings() (map[string]string, error) {
|
||||
return a.databaseService.GetAllAppSettings()
|
||||
}
|
||||
|
||||
// SaveOptimizerPreference saves optimizer options for a hero.
|
||||
func (a *App) SaveOptimizerPreference(heroID string, optionsJSON string) error {
|
||||
if a.databaseService == nil {
|
||||
return fmt.Errorf("database service not initialized")
|
||||
}
|
||||
if heroID == "" {
|
||||
return fmt.Errorf("hero id is required")
|
||||
}
|
||||
return a.databaseService.SaveOptimizerPreference(heroID, optionsJSON)
|
||||
}
|
||||
|
||||
// GetOptimizerPreference gets optimizer options for a hero.
|
||||
func (a *App) GetOptimizerPreference(heroID string) (string, error) {
|
||||
if a.databaseService == nil {
|
||||
return "", fmt.Errorf("database service not initialized")
|
||||
}
|
||||
if heroID == "" {
|
||||
return "", nil
|
||||
}
|
||||
return a.databaseService.GetOptimizerPreference(heroID)
|
||||
}
|
||||
|
||||
func logMissingSetStats(items []interface{}) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user