From bcf5d3d65772c29d588ee404edc6475999cb0976 Mon Sep 17 00:00:00 2001 From: kever Date: Sun, 31 May 2026 13:52:47 +0800 Subject: [PATCH] feat(database): add gearTxt field to parsed results and update related functions --- frontend/src/pages/OptimizerPage.tsx | 826 +++++++--- frontend/src/pages/optimizer.css | 313 +++- frontend/src/utils/optimizer.ts | 429 ++++++ frontend/wailsjs/go/service/App.d.ts | 8 + frontend/wailsjs/go/service/App.js | 16 + go.mod | 3 +- go.sum | 6 +- internal/model/database.go | 34 + internal/optimizer/optimizer.go | 2115 ++++++++++++++++++++++++++ internal/service/database_service.go | 19 + internal/service/main_service.go | 151 +- 11 files changed, 3635 insertions(+), 285 deletions(-) create mode 100644 frontend/src/utils/optimizer.ts create mode 100644 internal/optimizer/optimizer.go diff --git a/frontend/src/pages/OptimizerPage.tsx b/frontend/src/pages/OptimizerPage.tsx index f974ab7..3fe7216 100644 --- a/frontend/src/pages/OptimizerPage.tsx +++ b/frontend/src/pages/OptimizerPage.tsx @@ -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 = { + 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; + 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 = { + 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 => ({ + atk: 0, + def: 0, + hp: 0, + spd: 0, + cr: 0, + cd: 0, + acc: 0, + res: 0, +}); + +const cloneFilters = (filters: Partial> = {}): Filters => { + return STAT_LABELS.reduce((out, stat) => { + out[stat.key] = {...(filters[stat.key] || {})}; + return out; + }, {} as Filters); +}; + +const cloneSetFilters = (filters: Partial = {}): 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([]); @@ -98,28 +233,21 @@ export default function OptimizerPage() { const [selectedHeroId, setSelectedHeroId] = useState(null); const [setFilters, setSetFilters] = useState(emptySetFilters); const [statFilters, setStatFilters] = useState(emptyFilters); - const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({ - necklace: 'All', - ring: 'All', - boots: 'All', - }); - const [weightValues, setWeightValues] = useState>({ - atk: 3, - def: 3, - hp: 3, - spd: 3, - cr: 3, - cd: 3, - acc: 3, - res: 3, - }); + const [mainStatFilters, setMainStatFilters] = useState(createDefaultMainStatFilters); + const [weightValues, setWeightValues] = useState>(createDefaultWeightValues); const [results, setResults] = useState([]); const [selectedResult, setSelectedResult] = useState(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(() => createEmptyResultFilters()); const {success, error, info} = useMessage(); const [bonusByHeroId, setBonusByHeroId] = useState>({}); const [bonusEditorOpen, setBonusEditorOpen] = useState(false); const [bonusDraft, setBonusDraft] = useState(emptyBonusStats); + const [rememberPreferences, setRememberPreferences] = useState(false); + const preferenceLoadRef = useRef({initialized: false, applying: false, skipNextSave: false, heroKey: ''}); + const preferenceSaveTimerRef = useRef(null); const leftPanelsRef = useRef(null); const [leftPanelsHeight, setLeftPanelsHeight] = useState(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) => { + 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 = (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'; - error(msg); - console.error('OptimizeBuilds error:', err); + setOptimizing(false); + if (msg.includes('中断') || msg.toLowerCase().includes('canceled') || msg.toLowerCase().includes('cancelled')) { + info('配装计算已中断'); + } else { + error(msg); + } + 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) => {v}}, - {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) => {v}, + }, + {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 ? {getSetLabel([setKey])} : '-'; + }, + }, + { + 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 - +
属性
{STAT_LABELS.map(stat => ( @@ -574,7 +998,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
@@ -604,7 +1028,10 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat {renderSetBadge(setFilters.set3)} - - - @@ -767,24 +1125,61 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat -
+
+
+
+
结果筛选
+ +
+
+ {STAT_LABELS.map(stat => ( +
+ {stat.label} + updateResultFilter(stat.key, 'min', typeof value === 'number' ? value : null)} + /> + - + updateResultFilter(stat.key, 'max', typeof value === 'number' ? value : null)} + /> +
+ ))} +
+
+
+ +
组合数量:{totalCombos.toLocaleString()} | - 结果数量:{results.length.toLocaleString()} + 结果数量:{visibleResults.length.toLocaleString()} + {visibleResults.length !== results.length ? ( + <> / {results.length.toLocaleString()} + ) : null}
({ onClick: () => setSelectedResult(record), @@ -794,32 +1189,74 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat {selectedResult ? ( -
-
- }/> -
{selectedHero?.name || '未知英雄'}
-
- -
+ <> +
- 套装:{selectedResult.sets} + }/> +
{selectedHero?.name || '未知英雄'}
+
- 攻击:{selectedResult.atk},防御:{selectedResult.def},生命: - {selectedResult.hp},速度:{selectedResult.spd} -
-
- 暴击:{selectedResult.cr}% ,爆伤:{selectedResult.cd}% ,命中: - {selectedResult.acc}% ,抵抗:{selectedResult.res}% +
+ 套装:{selectedResult.sets} +
+
+ 攻击:{selectedResult.atk},防御:{selectedResult.def},生命: + {selectedResult.hp},速度:{selectedResult.spd} +
+
+ 暴击:{selectedResult.cr}% ,爆伤:{selectedResult.cd}% ,命中: + {selectedResult.acc}% ,抵抗:{selectedResult.res}% +
-
+
`${item.gear || 'gear'}-${item.id || ''}`} + scroll={{x: true}} + /> + ) : (
暂无配装结果
)} + + 中断计算 + , + ]} + > +
+ +
+ + 已计算 {optimizeProgress.checked.toLocaleString()} + {' '}个组合,符合过滤 {optimizeProgress.matched.toLocaleString()} + {' '}个组合 + + {optimizeProgress.percent.toFixed(1)}% +
+
+
{ 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)); }} > diff --git a/frontend/src/pages/optimizer.css b/frontend/src/pages/optimizer.css index 425d891..ae029c7 100644 --- a/frontend/src/pages/optimizer.css +++ b/frontend/src/pages/optimizer.css @@ -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)); + } } diff --git a/frontend/src/utils/optimizer.ts b/frontend/src/utils/optimizer.ts new file mode 100644 index 0000000..703dcb3 --- /dev/null +++ b/frontend/src/utils/optimizer.ts @@ -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; + +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) => { + 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, + 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(); + 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, 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(); + 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(); + 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(); + 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; +}; diff --git a/frontend/wailsjs/go/service/App.d.ts b/frontend/wailsjs/go/service/App.d.ts index 16c4fa4..e30f5cd 100644 --- a/frontend/wailsjs/go/service/App.d.ts +++ b/frontend/wailsjs/go/service/App.d.ts @@ -2,6 +2,8 @@ // This file is automatically generated. DO NOT EDIT import {model} from '../models'; +export function CancelOptimizeBuilds():Promise; + export function DeleteParsedSession(arg1:number):Promise; export function ExportCurrentData(arg1:string):Promise; @@ -24,6 +26,8 @@ export function GetLatestParsedDataFromDatabase():Promise; export function GetNetworkInterfaces():Promise>; +export function GetOptimizerPreference(arg1:string):Promise; + export function GetParsedDataByID(arg1:number):Promise; export function GetParsedSessions():Promise>; @@ -38,12 +42,16 @@ export function ReadRawJsonFile():Promise; export function SaveAppSetting(arg1:string,arg2:string):Promise; +export function SaveOptimizerPreference(arg1:string,arg2:string):Promise; + export function SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function StartCapture(arg1:string):Promise; export function StartCaptureWithFilter(arg1:string,arg2:string):Promise; +export function StartOptimizeBuilds(arg1:model.OptimizeRequest):Promise; + export function StopAndParseCapture():Promise; export function StopCapture():Promise; diff --git a/frontend/wailsjs/go/service/App.js b/frontend/wailsjs/go/service/App.js index ce0fe59..77337f0 100644 --- a/frontend/wailsjs/go/service/App.js +++ b/frontend/wailsjs/go/service/App.js @@ -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'](); } diff --git a/go.mod b/go.mod index f54aa74..2287a35 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f011ea9..8eb776d 100644 --- a/go.sum +++ b/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= diff --git a/internal/model/database.go b/internal/model/database.go index 8a27bd2..f27b895 100644 --- a/internal/model/database.go +++ b/internal/model/database.go @@ -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 +} diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go new file mode 100644 index 0000000..999d16f --- /dev/null +++ b/internal/optimizer/optimizer.go @@ -0,0 +1,2115 @@ +package optimizer + +import ( + "container/heap" + "context" + "encoding/json" + "fmt" + "log" + "math" + "runtime" + "sort" + "sync" + "sync/atomic" + + "equipment-analyzer/internal/model" +) + +const ( + defaultMaxItemsPerSlot = 300 + defaultMaxCombos = 2000000 + defaultMaxResults = 5000 + maxCollectedPerPass = 5000 + maxCandidateItemsPerSlot = 5000 + maxParallelSearchPasses = 4 + progressReportInterval = 10000 + setCoverageSearchBonus = 1000000 +) + +var gearSlots = []string{"Weapon", "Helmet", "Armor", "Necklace", "Ring", "Boots"} +var statKeys = []string{"atk", "def", "hp", "spd", "cr", "cd", "acc", "res"} + +var fourPieceSets = map[string]bool{ + "AttackSet": true, + "SpeedSet": true, + "DestructionSet": true, + "LifestealSet": true, + "ProtectionSet": true, + "CounterSet": true, + "RageSet": true, + "RevengeSet": true, + "InjurySet": true, + "ReversalSet": true, + "RiposteSet": true, + "WarfareSet": true, +} + +var twoPieceSets = map[string]bool{ + "HealthSet": true, + "DefenseSet": true, + "CriticalSet": true, + "HitSet": true, + "ResistSet": true, + "UnitySet": true, + "ImmunitySet": true, + "PenetrationSet": true, + "TorrentSet": true, + "PursuitSet": true, +} + +type itemStats struct { + atk float64 + def float64 + hp float64 + spd float64 + cr float64 + cd float64 + acc float64 + res float64 + atkPct float64 + defPct float64 + hpPct float64 +} + +type baseStats struct { + atk float64 + def float64 + hp float64 + spd float64 + cr float64 + cd float64 + acc float64 + res float64 +} + +// ParseItems converts raw interface data to optimizer items. +func ParseItems(items []interface{}) ([]model.OptimizeItem, error) { + if len(items) == 0 { + return []model.OptimizeItem{}, nil + } + data, err := json.Marshal(items) + if err != nil { + return nil, err + } + var out []model.OptimizeItem + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + for i := range out { + if out[i].Set == "" && out[i].F != "" { + if mapped, ok := ingameSetMap[out[i].F]; ok { + out[i].Set = mapped + } + } + } + return out, nil +} + +type ProgressCallback func(checked int, total int, matched int) + +type scoredOptimizeItem struct { + item model.OptimizeItem + stats itemStats + score float64 +} + +type searchSlotItem struct { + item model.OptimizeItem + stats itemStats + score float64 + requiredSetIndex int +} + +type optimizeCandidateItem struct { + item model.OptimizeItem + stats itemStats + score float64 +} + +type optimizeSearchPass struct { + objective string + req model.OptimizeRequest + topItemsByGear [][]scoredOptimizeItem + maxCombos int +} + +type optimizePassExecutionResult struct { + results []model.OptimizeResult + checked int + matched int + err error +} + +// ParseHeroes converts raw interface data to optimizer heroes. +func ParseHeroes(heroes []interface{}) ([]model.OptimizeHero, error) { + if len(heroes) == 0 { + return []model.OptimizeHero{}, nil + } + data, err := json.Marshal(heroes) + if err != nil { + return nil, err + } + var out []model.OptimizeHero + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func Optimize(items []model.OptimizeItem, heroes []model.OptimizeHero, req model.OptimizeRequest) (*model.OptimizeResponse, error) { + return OptimizeWithContext(context.Background(), items, heroes, req, nil) +} + +func OptimizeWithContext(ctx context.Context, items []model.OptimizeItem, heroes []model.OptimizeHero, req model.OptimizeRequest, progress ProgressCallback) (*model.OptimizeResponse, error) { + if ctx == nil { + ctx = context.Background() + } + if req.HeroID == "" { + return nil, fmt.Errorf("hero code is required") + } + + hero := findHeroByCode(heroes, req.HeroID) + if hero == nil { + return nil, fmt.Errorf("hero not found") + } + + maxItems := req.MaxItemsPerSlot + if maxItems <= 0 { + maxItems = defaultMaxItemsPerSlot + } + maxCombos := req.MaxCombos + if maxCombos <= 0 { + maxCombos = defaultMaxCombos + } + maxResults := req.MaxResults + if maxResults <= 0 { + maxResults = defaultMaxResults + } + + base := buildBaseStats(hero) + bonus := req.BonusStats + + setRequirements, requiredSetPieces, validSetFilters := buildSetRequirements(req.SetFilters) + if !validSetFilters { + return nil, fmt.Errorf("invalid set filter combination: selected set effects require more than 6 gear pieces") + } + if len(setRequirements) == 0 { + return nil, fmt.Errorf("set filter is required") + } + if !hasAnyWeightPreference(req.WeightValues) { + return nil, fmt.Errorf("attribute preference is required") + } + restrictToSelectedSets := len(setRequirements) > 0 && requiredSetPieces == len(gearSlots) + + itemsByGear := map[string][]model.OptimizeItem{} + for _, slot := range gearSlots { + itemsByGear[slot] = []model.OptimizeItem{} + } + for _, item := range items { + if item.Gear == "" { + continue + } + if !itemPassesMainStatFilter(item, req.MainStatFilters) { + continue + } + if restrictToSelectedSets { + if item.Set == "" { + continue + } + if _, ok := setRequirements[item.Set]; !ok { + continue + } + } + if _, ok := itemsByGear[item.Gear]; ok { + itemsByGear[item.Gear] = append(itemsByGear[item.Gear], item) + } + } + + statsCache := buildStatsCache(itemsByGear) + searchObjectives := buildSearchObjectives(req) + searchPasses := buildOptimizeSearchPasses(itemsByGear, statsCache, base, req, maxItems, maxCombos, setRequirements, !restrictToSelectedSets, searchObjectives) + totalExpectedCombos := 0 + for _, pass := range searchPasses { + totalExpectedCombos += estimatePassCombos(pass.topItemsByGear, pass.maxCombos) + } + if progress != nil { + progress(0, totalExpectedCombos, 0) + } + + passResults, checkedCount, matchedCount, uniqueMatchedCount, err := collectOptimizeSearchPasses( + ctx, + searchPasses, + statsCache, + base, + bonus, + req, + setRequirements, + totalExpectedCombos, + progress, + ) + if err != nil { + return nil, err + } + + resultByKey := map[string]model.OptimizeResult{} + for _, passResult := range passResults { + for _, result := range passResult.results { + resultByKey[result.Key] = result + } + } + + results := make([]model.OptimizeResult, 0, len(resultByKey)) + for _, result := range resultByKey { + results = append(results, result) + } + + results = selectOptimizeResults(results, maxResults, searchObjectives) + + log.Printf("[optimizer] checked=%d matched=%d unique=%d retained=%d returned=%d", checkedCount, matchedCount, uniqueMatchedCount, len(resultByKey), len(results)) + if progress != nil { + progress(totalExpectedCombos, totalExpectedCombos, uniqueMatchedCount) + } + + totalCombos := uniqueMatchedCount + return &model.OptimizeResponse{ + TotalCombos: totalCombos, + Results: results, + }, nil +} + +func collectOptimizeSearchPasses( + ctx context.Context, + searchPasses []optimizeSearchPass, + statsCache map[string]itemStats, + base baseStats, + bonus model.BonusStats, + req model.OptimizeRequest, + setRequirements map[string]int, + totalExpectedCombos int, + progress ProgressCallback, +) ([]optimizePassExecutionResult, int, int, int, error) { + if len(searchPasses) == 0 { + return nil, 0, 0, 0, nil + } + + workerCtx, cancel := context.WithCancel(ctx) + defer cancel() + + jobs := make(chan int, len(searchPasses)) + results := make(chan optimizePassExecutionResult, len(searchPasses)) + passChecked := make([]int64, len(searchPasses)) + uniqueMatched := map[string]struct{}{} + var progressMu sync.Mutex + lastProgressChecked := 0 + lastProgressMatched := 0 + + reportProgress := func() { + if progress == nil { + return + } + checked := sumAtomicInts(passChecked) + progressMu.Lock() + matched := len(uniqueMatched) + if checked < lastProgressChecked { + checked = lastProgressChecked + } else { + lastProgressChecked = checked + } + if matched < lastProgressMatched { + matched = lastProgressMatched + } else { + lastProgressMatched = matched + } + progress(checked, totalExpectedCombos, matched) + progressMu.Unlock() + } + + recordMatched := func(key string) { + if key == "" { + return + } + progressMu.Lock() + uniqueMatched[key] = struct{}{} + progressMu.Unlock() + } + + for index := range searchPasses { + jobs <- index + } + close(jobs) + + workerCount := optimizeWorkerCount(len(searchPasses)) + for worker := 0; worker < workerCount; worker++ { + go func() { + for index := range jobs { + pass := searchPasses[index] + passResults, checked, matched, err := collectOptimizeResults( + workerCtx, + pass.topItemsByGear, + statsCache, + base, + bonus, + pass.req, + pass.objective, + setRequirements, + req.WeightValues, + pass.maxCombos, + func(checked int, _ int) { + atomic.StoreInt64(&passChecked[index], int64(checked)) + reportProgress() + }, + recordMatched, + ) + atomic.StoreInt64(&passChecked[index], int64(checked)) + reportProgress() + if err != nil { + cancel() + } + results <- optimizePassExecutionResult{ + results: passResults, + checked: checked, + matched: matched, + err: err, + } + } + }() + } + + passResults := make([]optimizePassExecutionResult, 0, len(searchPasses)) + var firstErr error + for range searchPasses { + result := <-results + passResults = append(passResults, result) + if result.err != nil && firstErr == nil { + firstErr = result.err + cancel() + } + } + + checkedCount := 0 + matchedCount := 0 + for _, result := range passResults { + checkedCount += result.checked + matchedCount += result.matched + } + progressMu.Lock() + uniqueMatchedCount := len(uniqueMatched) + progressMu.Unlock() + return passResults, checkedCount, matchedCount, uniqueMatchedCount, firstErr +} + +func optimizeWorkerCount(passCount int) int { + if passCount <= 1 { + return passCount + } + workers := runtime.NumCPU() + if workers > 1 { + workers-- + } + if workers < 1 { + workers = 1 + } + if workers > maxParallelSearchPasses { + workers = maxParallelSearchPasses + } + if workers > passCount { + workers = passCount + } + return workers +} + +func sumAtomicInts(values []int64) int { + total := 0 + for index := range values { + total += int(atomic.LoadInt64(&values[index])) + } + return total +} + +func buildOptimizeSearchPasses( + itemsByGear map[string][]model.OptimizeItem, + statsCache map[string]itemStats, + base baseStats, + req model.OptimizeRequest, + maxItems int, + maxCombos int, + setRequirements map[string]int, + preferSelectedSets bool, + objectives []string, +) []optimizeSearchPass { + if len(objectives) == 0 { + objectives = []string{""} + } + + passes := make([]optimizeSearchPass, len(objectives)) + jobs := make(chan int, len(objectives)) + for index := range objectives { + jobs <- index + } + close(jobs) + + var wg sync.WaitGroup + workerCount := optimizeWorkerCount(len(objectives)) + wg.Add(workerCount) + for worker := 0; worker < workerCount; worker++ { + go func() { + defer wg.Done() + for index := range jobs { + objective := objectives[index] + passReq := req + passReq.WeightValues = weightsForObjective(req.WeightValues, objective) + passes[index] = optimizeSearchPass{ + objective: objective, + req: passReq, + topItemsByGear: buildTopItemsByGear(itemsByGear, statsCache, base, passReq, maxItems, setRequirements, preferSelectedSets), + maxCombos: maxCombos, + } + } + }() + } + wg.Wait() + return passes +} + +func estimatePassCombos(topItemsByGear [][]scoredOptimizeItem, maxCombos int) int { + if len(topItemsByGear) != len(gearSlots) || maxCombos <= 0 { + return 0 + } + total := 1 + for _, slotItems := range topItemsByGear { + if len(slotItems) == 0 { + return 0 + } + total *= len(slotItems) + if total >= maxCombos { + return maxCombos + } + } + return total +} + +func buildStatsCache(itemsByGear map[string][]model.OptimizeItem) map[string]itemStats { + statsCache := map[string]itemStats{} + for _, items := range itemsByGear { + for _, item := range items { + statsCache[itemKey(item)] = buildItemStats(item) + } + } + return statsCache +} + +func buildTopItemsByGear( + itemsByGear map[string][]model.OptimizeItem, + statsCache map[string]itemStats, + base baseStats, + req model.OptimizeRequest, + maxItems int, + setRequirements map[string]int, + preferSelectedSets bool, +) [][]scoredOptimizeItem { + focusKeys := buildCandidateFocusKeys(req) + topItemsByGear := make([][]scoredOptimizeItem, 0, len(gearSlots)) + for _, slot := range gearSlots { + slotItems := itemsByGear[slot] + scored := make([]optimizeCandidateItem, 0, len(slotItems)) + for _, item := range slotItems { + stats := statsCache[itemKey(item)] + score := scoreCandidateItem(stats, base, req) + scored = append(scored, optimizeCandidateItem{item: item, stats: stats, score: score}) + } + sort.Slice(scored, func(i, j int) bool { + if scored[i].score == scored[j].score { + return itemKey(scored[i].item) < itemKey(scored[j].item) + } + return scored[i].score > scored[j].score + }) + + requestedLimit := maxItems + if requestedLimit > len(scored) { + requestedLimit = len(scored) + } + + requiredSetKeys := sortedRequiredSetKeys(setRequirements) + candidateLimit := candidateLimitForSlot(len(scored), requestedLimit, focusKeys, requiredSetKeys, preferSelectedSets) + selected := buildCandidateUnion(scored, focusKeys, requiredSetKeys, base, requestedLimit, preferSelectedSets) + selected = paretoPruneCandidates(selected, focusKeys, base, setRequirements) + selected = capCandidatesByChannels(selected, focusKeys, requiredSetKeys, base, candidateLimit, preferSelectedSets) + + sort.Slice(selected, func(i, j int) bool { + if selected[i].score == selected[j].score { + return itemKey(selected[i].item) < itemKey(selected[j].item) + } + return selected[i].score > selected[j].score + }) + if len(selected) > candidateLimit { + selected = selected[:candidateLimit] + } + topItemsByGear = append(topItemsByGear, selected) + } + return topItemsByGear +} + +func buildCandidateUnion( + scored []optimizeCandidateItem, + focusKeys []string, + requiredSetKeys []string, + base baseStats, + limit int, + preferSelectedSets bool, +) []scoredOptimizeItem { + if len(scored) == 0 || limit <= 0 { + return []scoredOptimizeItem{} + } + channelQuota := limit + if len(focusKeys) > 0 { + channelQuota = limit / 2 + if channelQuota < 1 { + channelQuota = 1 + } + } + if channelQuota > len(scored) { + channelQuota = len(scored) + } + + selected := make([]scoredOptimizeItem, 0, minInt(len(scored), maxCandidateItemsPerSlot)) + selectedKeys := map[string]struct{}{} + addCandidates := func(candidates []optimizeCandidateItem, quota int) { + for _, candidate := range candidates { + if len(selected) >= maxCandidateItemsPerSlot || quota <= 0 { + return + } + key := itemKey(candidate.item) + if _, ok := selectedKeys[key]; ok { + continue + } + selectedKeys[key] = struct{}{} + selected = append(selected, scoredOptimizeItem{ + item: candidate.item, + stats: candidate.stats, + score: candidate.score, + }) + quota-- + } + } + + addCandidates(scored, channelQuota) + for _, key := range focusKeys { + addCandidates(rankCandidatesByStat(scored, base, key), channelQuota) + } + if preferSelectedSets { + for _, setKey := range requiredSetKeys { + setCandidates := filterCandidatesBySet(scored, setKey) + addCandidates(setCandidates, channelQuota) + for _, key := range focusKeys { + addCandidates(rankCandidatesByStat(setCandidates, base, key), channelQuota) + } + } + } + return selected +} + +func candidateLimitForSlot( + totalItems int, + requestedLimit int, + focusKeys []string, + requiredSetKeys []string, + preferSelectedSets bool, +) int { + if totalItems <= 0 || requestedLimit <= 0 { + return 0 + } + channelCount := 1 + len(focusKeys) + if preferSelectedSets { + channelCount += len(requiredSetKeys) * (1 + len(focusKeys)) + } + limit := requestedLimit + if channelCount > 1 { + limit = requestedLimit * channelCount + } + if limit > maxCandidateItemsPerSlot { + limit = maxCandidateItemsPerSlot + } + if limit > totalItems { + limit = totalItems + } + return limit +} + +func paretoPruneCandidates( + selected []scoredOptimizeItem, + focusKeys []string, + base baseStats, + setRequirements map[string]int, +) []scoredOptimizeItem { + if len(selected) <= 1 || len(focusKeys) == 0 { + return selected + } + bySet := map[string][]scoredOptimizeItem{} + for _, candidate := range selected { + bySet[candidate.item.Set] = append(bySet[candidate.item.Set], candidate) + } + out := make([]scoredOptimizeItem, 0, len(selected)) + for _, group := range bySet { + for i, candidate := range group { + dominated := false + for j, other := range group { + if i == j { + continue + } + if dominatesCandidate(other, candidate, focusKeys, base, setRequirements) { + dominated = true + break + } + } + if !dominated { + out = append(out, candidate) + } + } + } + return out +} + +func capCandidatesByChannels( + selected []scoredOptimizeItem, + focusKeys []string, + requiredSetKeys []string, + base baseStats, + limit int, + preferSelectedSets bool, +) []scoredOptimizeItem { + if len(selected) <= limit || limit <= 0 { + return selected + } + candidates := optimizeCandidatesFromScored(selected) + sort.Slice(candidates, func(i, j int) bool { + return compareCandidateScore(candidates[i], candidates[j]) + }) + + channelCount := 1 + len(focusKeys) + if preferSelectedSets { + channelCount += len(requiredSetKeys) * (1 + len(focusKeys)) + } + channelQuota := limit / channelCount + if channelQuota < 1 { + channelQuota = 1 + } + + out := make([]scoredOptimizeItem, 0, limit) + outKeys := map[string]struct{}{} + addScored := func(items []scoredOptimizeItem, quota int) { + for _, item := range items { + if len(out) >= limit || quota <= 0 { + return + } + key := itemKey(item.item) + if _, ok := outKeys[key]; ok { + continue + } + outKeys[key] = struct{}{} + out = append(out, item) + quota-- + } + } + addCandidates := func(items []optimizeCandidateItem, quota int) { + scoredItems := make([]scoredOptimizeItem, 0, len(items)) + for _, item := range items { + scoredItems = append(scoredItems, scoredOptimizeItem{item: item.item, stats: item.stats, score: item.score}) + } + addScored(scoredItems, quota) + } + + addCandidates(candidates, channelQuota) + for _, key := range focusKeys { + addCandidates(rankCandidatesByStat(candidates, base, key), channelQuota) + } + if preferSelectedSets { + for _, setKey := range requiredSetKeys { + setCandidates := filterCandidatesBySet(candidates, setKey) + addCandidates(setCandidates, channelQuota) + for _, key := range focusKeys { + addCandidates(rankCandidatesByStat(setCandidates, base, key), channelQuota) + } + } + } + if len(out) < limit { + addCandidates(candidates, limit-len(out)) + } + return out +} + +func dominatesCandidate( + left scoredOptimizeItem, + right scoredOptimizeItem, + focusKeys []string, + base baseStats, + setRequirements map[string]int, +) bool { + if requiredSetRank(left.item.Set, setRequirements) < requiredSetRank(right.item.Set, setRequirements) { + return false + } + strictlyBetter := left.score > right.score + for _, key := range focusKeys { + leftValue := itemStatContribution(left.stats, base, key) + rightValue := itemStatContribution(right.stats, base, key) + if leftValue < rightValue { + return false + } + if leftValue > rightValue { + strictlyBetter = true + } + } + return strictlyBetter +} + +func requiredSetRank(setName string, setRequirements map[string]int) int { + if setName == "" { + return 0 + } + if _, ok := setRequirements[setName]; ok { + return 1 + } + return 0 +} + +func rankCandidatesByStat(candidates []optimizeCandidateItem, base baseStats, key string) []optimizeCandidateItem { + ranked := append([]optimizeCandidateItem(nil), candidates...) + sort.Slice(ranked, func(i, j int) bool { + left := itemStatContribution(ranked[i].stats, base, key) + right := itemStatContribution(ranked[j].stats, base, key) + if left == right { + return compareCandidateScore(ranked[i], ranked[j]) + } + return left > right + }) + return ranked +} + +func filterCandidatesBySet(candidates []optimizeCandidateItem, setKey string) []optimizeCandidateItem { + out := make([]optimizeCandidateItem, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.item.Set == setKey { + out = append(out, candidate) + } + } + return out +} + +func optimizeCandidatesFromScored(items []scoredOptimizeItem) []optimizeCandidateItem { + out := make([]optimizeCandidateItem, 0, len(items)) + for _, item := range items { + out = append(out, optimizeCandidateItem{ + item: item.item, + stats: item.stats, + score: item.score, + }) + } + return out +} + +func compareCandidateScore(left optimizeCandidateItem, right optimizeCandidateItem) bool { + if left.score == right.score { + return itemKey(left.item) < itemKey(right.item) + } + return left.score > right.score +} + +func minInt(left int, right int) int { + if left < right { + return left + } + return right +} + +func buildCandidateFocusKeys(req model.OptimizeRequest) []string { + focusKeys := make([]string, 0, len(statKeys)) + seen := map[string]struct{}{} + addKey := func(key string) { + if !isStatKey(key) { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + focusKeys = append(focusKeys, key) + } + + weightedKeys := make([]string, 0, len(statKeys)) + for _, key := range statKeys { + if req.WeightValues[key] > 0 { + weightedKeys = append(weightedKeys, key) + } + } + sort.SliceStable(weightedKeys, func(i, j int) bool { + left := req.WeightValues[weightedKeys[i]] + right := req.WeightValues[weightedKeys[j]] + if left == right { + return weightedKeys[i] < weightedKeys[j] + } + return left > right + }) + for _, key := range weightedKeys { + addKey(key) + } + + for _, key := range statKeys { + if rangeVal, ok := req.StatFilters[key]; ok { + if rangeVal.Min != nil || rangeVal.Max != nil { + addKey(key) + } + } + } + + return focusKeys +} + +func buildSearchObjectives(req model.OptimizeRequest) []string { + objectives := []string{""} + weightedKeys := weightedStatKeys(req.WeightValues) + if len(weightedKeys) > 0 { + return append(objectives, weightedKeys...) + } + return append(objectives, statKeys...) +} + +func weightedStatKeys(weights map[string]float64) []string { + keys := make([]string, 0, len(statKeys)) + for _, key := range statKeys { + if weights[key] > 0 { + keys = append(keys, key) + } + } + sort.SliceStable(keys, func(i, j int) bool { + left := weights[keys[i]] + right := weights[keys[j]] + if left == right { + return keys[i] < keys[j] + } + return left > right + }) + return keys +} + +func sortedRequiredSetKeys(setRequirements map[string]int) []string { + keys := make([]string, 0, len(setRequirements)) + for key := range setRequirements { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func cloneWeights(weights map[string]float64) map[string]float64 { + if len(weights) == 0 { + return nil + } + out := make(map[string]float64, len(weights)) + for key, value := range weights { + out[key] = value + } + return out +} + +func weightsForObjective(weights map[string]float64, objective string) map[string]float64 { + if objective == "" || !isStatKey(objective) { + return cloneWeights(weights) + } + out := make(map[string]float64, len(statKeys)) + for _, key := range statKeys { + out[key] = 0 + } + if weights[objective] > 0 { + out[objective] = weights[objective] + } else { + out[objective] = 1 + } + return out +} + +func hasAnyWeightPreference(weights map[string]float64) bool { + for _, key := range statKeys { + if weights[key] > 0 { + return true + } + } + return false +} + +func isStatKey(key string) bool { + switch key { + case "atk", "def", "hp", "spd", "cr", "cd", "acc", "res": + return true + default: + return false + } +} + +func collectOptimizeResults( + ctx context.Context, + topItemsByGear [][]scoredOptimizeItem, + statsCache map[string]itemStats, + base baseStats, + bonus model.BonusStats, + req model.OptimizeRequest, + objective string, + setRequirements map[string]int, + scoreWeights map[string]float64, + maxCombos int, + onProgress func(checked int, matched int), + onMatched func(key string), +) ([]model.OptimizeResult, int, int, error) { + if len(topItemsByGear) != 6 { + return []model.OptimizeResult{}, 0, 0, nil + } + searchItemsByGear, requiredPieces := buildSearchItemsByGear(topItemsByGear, setRequirements) + if len(searchItemsByGear) != 6 { + return []model.OptimizeResult{}, 0, 0, nil + } + + checkedCount := 0 + reportedCount := 0 + matchedCount := 0 + retained := &resultHeap{objective: objective} + heap.Init(retained) + + startIndices := [6]int{} + startScore := 0.0 + for slot := 0; slot < len(searchItemsByGear); slot++ { + if len(searchItemsByGear[slot]) == 0 { + return retained.items, checkedCount, matchedCount, nil + } + startScore += searchItemsByGear[slot][0].score + } + startScore += setCoverageScoreForSearch(searchItemsByGear, startIndices, requiredPieces) + startStates := []combinationState{{indices: startIndices, score: startScore}} + for _, setSeed := range buildSetCoverageSeedStates(searchItemsByGear, requiredPieces) { + if setSeed.indices == startIndices { + continue + } + startStates = append(startStates, setSeed) + } + + states := &combinationHeap{} + heap.Init(states) + visited := map[combinationVisitKey]struct{}{} + for _, state := range startStates { + visitKey := packCombinationVisitKey(state.indices) + if _, ok := visited[visitKey]; ok { + continue + } + visited[visitKey] = struct{}{} + heap.Push(states, state) + } + + for states.Len() > 0 && checkedCount < maxCombos { + state := heap.Pop(states).(combinationState) + checkedCount++ + if checkedCount-reportedCount >= progressReportInterval { + if err := ctx.Err(); err != nil { + if onProgress != nil { + onProgress(checkedCount, matchedCount) + } + return retained.items, checkedCount, matchedCount, err + } + if onProgress != nil { + onProgress(checkedCount, matchedCount) + } + reportedCount = checkedCount + } + + setCounts := setCountsArrayForSearch(searchItemsByGear, state.indices, len(requiredPieces)) + currentCoverageScore := setCoverageScoreFromCountArray(setCounts, requiredPieces) + + if passesRequiredSetCountsArray(setCounts, requiredPieces) { + totals := buildTotalStatsForSearch(searchItemsByGear, state.indices, base, bonus) + if passesFilters(totals, req.StatFilters) { + matchedCount++ + itemsList := buildItemsListForSearch(searchItemsByGear, state.indices) + completed := getCompletedSets(itemsList) + setName := "无套装" + if len(completed) > 0 { + setName = joinSets(completed) + } + key := buildKey(itemsList) + if onMatched != nil { + onMatched(key) + } + result := model.OptimizeResult{ + Key: key, + Sets: setName, + Atk: panelWholeStat(totals.atk), + Def: panelWholeStat(totals.def), + Hp: panelWholeStat(totals.hp), + Spd: panelRoundedStat(totals.spd), + Cr: math.Round(totals.cr), + Cd: math.Round(totals.cd), + Acc: math.Round(totals.acc), + Res: math.Round(totals.res), + Score: scoreTotals(totals, scoreWeights), + Items: itemsList, + } + retainResult(retained, result, maxCollectedPerPass) + } + } + + for slot := 0; slot < len(searchItemsByGear); slot++ { + nextIndices := state.indices + nextIndices[slot]++ + if nextIndices[slot] >= len(searchItemsByGear[slot]) { + continue + } + nextVisitKey := packCombinationVisitKey(nextIndices) + if _, ok := visited[nextVisitKey]; ok { + continue + } + visited[nextVisitKey] = struct{}{} + nextScore := state.score - + searchItemsByGear[slot][state.indices[slot]].score + + searchItemsByGear[slot][nextIndices[slot]].score + nextCounts := setCounts + currentSetIndex := searchItemsByGear[slot][state.indices[slot]].requiredSetIndex + if currentSetIndex >= 0 && currentSetIndex < len(requiredPieces) { + nextCounts[currentSetIndex]-- + } + nextSetIndex := searchItemsByGear[slot][nextIndices[slot]].requiredSetIndex + if nextSetIndex >= 0 && nextSetIndex < len(requiredPieces) { + nextCounts[nextSetIndex]++ + } + nextScore += setCoverageScoreFromCountArray(nextCounts, requiredPieces) - currentCoverageScore + heap.Push(states, combinationState{ + indices: nextIndices, + score: nextScore, + }) + } + } + + if onProgress != nil { + onProgress(checkedCount, matchedCount) + } + + return retained.items, checkedCount, matchedCount, nil +} + +func buildSearchItemsByGear(topItemsByGear [][]scoredOptimizeItem, setRequirements map[string]int) ([][]searchSlotItem, []int) { + requiredSetKeys := sortedRequiredSetKeys(setRequirements) + requiredIndex := map[string]int{} + requiredPieces := make([]int, len(requiredSetKeys)) + for index, setName := range requiredSetKeys { + requiredIndex[setName] = index + requiredPieces[index] = setRequirements[setName] + } + + out := make([][]searchSlotItem, 0, len(topItemsByGear)) + for _, slotItems := range topItemsByGear { + items := make([]searchSlotItem, 0, len(slotItems)) + for _, candidate := range slotItems { + setIndex := -1 + if index, ok := requiredIndex[candidate.item.Set]; ok { + setIndex = index + } + items = append(items, searchSlotItem{ + item: candidate.item, + stats: candidate.stats, + score: candidate.score, + requiredSetIndex: setIndex, + }) + } + out = append(out, items) + } + return out, requiredPieces +} + +func buildSetCoverageSeedStates(searchItemsByGear [][]searchSlotItem, requiredPieces []int) []combinationState { + if len(requiredPieces) == 0 || len(searchItemsByGear) != len(gearSlots) { + return nil + } + + assignments := buildRequiredSetSlotAssignments(requiredPieces) + seeds := make([]combinationState, 0, len(assignments)) + for _, assignment := range assignments { + indices := [6]int{} + valid := true + for slot, setIndex := range assignment { + index := firstCandidateIndexForRequiredSet(searchItemsByGear[slot], setIndex) + if index < 0 { + valid = false + break + } + indices[slot] = index + } + if !valid { + continue + } + seeds = append(seeds, combinationState{ + indices: indices, + score: combinationScoreForSearch(searchItemsByGear, indices) + setCoverageScoreForSearch(searchItemsByGear, indices, requiredPieces), + }) + } + return seeds +} + +func buildRequiredSetSlotAssignments(requiredPieces []int) []map[int]int { + assignments := []map[int]int{{}} + for setIndex, pieces := range requiredPieces { + if pieces <= 0 { + continue + } + nextAssignments := make([]map[int]int, 0, len(assignments)) + for _, assignment := range assignments { + availableSlots := make([]int, 0, len(gearSlots)) + for slot := range gearSlots { + if _, used := assignment[slot]; !used { + availableSlots = append(availableSlots, slot) + } + } + for _, slots := range chooseSlotCombos(availableSlots, pieces) { + next := cloneSlotAssignment(assignment) + for _, slot := range slots { + next[slot] = setIndex + } + nextAssignments = append(nextAssignments, next) + } + } + assignments = nextAssignments + } + return assignments +} + +func chooseSlotCombos(slots []int, count int) [][]int { + if count <= 0 { + return [][]int{{}} + } + if count > len(slots) { + return nil + } + var out [][]int + var current []int + var walk func(start int) + walk = func(start int) { + if len(current) == count { + combo := append([]int(nil), current...) + out = append(out, combo) + return + } + needed := count - len(current) + for i := start; i <= len(slots)-needed; i++ { + current = append(current, slots[i]) + walk(i + 1) + current = current[:len(current)-1] + } + } + walk(0) + return out +} + +func cloneSlotAssignment(assignment map[int]int) map[int]int { + out := make(map[int]int, len(assignment)) + for slot, setIndex := range assignment { + out[slot] = setIndex + } + return out +} + +func firstCandidateIndexForRequiredSet(items []searchSlotItem, setIndex int) int { + for index, candidate := range items { + if candidate.requiredSetIndex == setIndex { + return index + } + } + return -1 +} + +func setCoverageScoreForSearch(searchItemsByGear [][]searchSlotItem, indices [6]int, requiredPieces []int) float64 { + if len(requiredPieces) == 0 || len(searchItemsByGear) != len(gearSlots) { + return 0 + } + counts := setCountsArrayForSearch(searchItemsByGear, indices, len(requiredPieces)) + return setCoverageScoreFromCountArray(counts, requiredPieces) +} + +func setCoverageScoreFromCountArray(counts [6]int, requiredPieces []int) float64 { + score := 0.0 + for setIndex, required := range requiredPieces { + if setIndex >= len(counts) { + break + } + if required <= 0 { + continue + } + covered := counts[setIndex] + if covered > required { + covered = required + } + score += float64(covered) * setCoverageSearchBonus + } + return score +} + +func setCountsArrayForSearch(searchItemsByGear [][]searchSlotItem, indices [6]int, requiredSetCount int) [6]int { + var counts [6]int + if requiredSetCount <= 0 { + return counts + } + if requiredSetCount > len(counts) { + requiredSetCount = len(counts) + } + for slot := 0; slot < len(searchItemsByGear); slot++ { + index := indices[slot] + if index < 0 || index >= len(searchItemsByGear[slot]) { + continue + } + setIndex := searchItemsByGear[slot][index].requiredSetIndex + if setIndex >= 0 && setIndex < requiredSetCount { + counts[setIndex]++ + } + } + return counts +} + +func passesRequiredSetCountsArray(counts [6]int, requiredPieces []int) bool { + for index, required := range requiredPieces { + if required <= 0 { + continue + } + if index >= len(counts) || counts[index] < required { + return false + } + } + return true +} + +func packCombinationVisitKey(indices [6]int) combinationVisitKey { + return combinationVisitKey{ + lo: uint64(uint32(indices[0])) | + uint64(uint32(indices[1]))<<32, + hi: uint64(uint32(indices[2])) | + uint64(uint32(indices[3]))<<16 | + uint64(uint32(indices[4]))<<32 | + uint64(uint32(indices[5]))<<48, + } +} + +func combinationScoreForSearch(searchItemsByGear [][]searchSlotItem, indices [6]int) float64 { + score := 0.0 + for slot := 0; slot < len(searchItemsByGear); slot++ { + score += searchItemsByGear[slot][indices[slot]].score + } + return score +} + +func resultObjectiveValue(result model.OptimizeResult, objective string) float64 { + switch objective { + case "atk": + return result.Atk + case "def": + return result.Def + case "hp": + return result.Hp + case "spd": + return result.Spd + case "cr": + return result.Cr + case "cd": + return result.Cd + case "acc": + return result.Acc + case "res": + return result.Res + default: + return result.Score + } +} + +func compareResultsForObjective(a model.OptimizeResult, b model.OptimizeResult, objective string) int { + aPrimary := resultObjectiveValue(a, objective) + bPrimary := resultObjectiveValue(b, objective) + if aPrimary < bPrimary { + return -1 + } + if aPrimary > bPrimary { + return 1 + } + if a.Score < b.Score { + return -1 + } + if a.Score > b.Score { + return 1 + } + if a.Key < b.Key { + return -1 + } + if a.Key > b.Key { + return 1 + } + return 0 +} + +type resultHeap struct { + objective string + items []model.OptimizeResult +} + +type combinationState struct { + indices [6]int + score float64 +} + +type combinationVisitKey struct { + lo uint64 + hi uint64 +} + +type combinationHeap []combinationState + +func (h combinationHeap) Len() int { + return len(h) +} + +func (h combinationHeap) Less(i int, j int) bool { + return h[i].score > h[j].score +} + +func (h combinationHeap) Swap(i int, j int) { + h[i], h[j] = h[j], h[i] +} + +func (h *combinationHeap) Push(x interface{}) { + *h = append(*h, x.(combinationState)) +} + +func (h *combinationHeap) Pop() interface{} { + old := *h + n := len(old) + item := old[n-1] + *h = old[:n-1] + return item +} + +func (h resultHeap) Len() int { + return len(h.items) +} + +func (h resultHeap) Less(i int, j int) bool { + return compareResultsForObjective(h.items[i], h.items[j], h.objective) < 0 +} + +func (h resultHeap) Swap(i int, j int) { + h.items[i], h.items[j] = h.items[j], h.items[i] +} + +func (h *resultHeap) Push(x interface{}) { + h.items = append(h.items, x.(model.OptimizeResult)) +} + +func (h *resultHeap) Pop() interface{} { + old := h.items + n := len(old) + item := old[n-1] + h.items = old[:n-1] + return item +} + +func retainResult(h *resultHeap, result model.OptimizeResult, limit int) { + if limit <= 0 { + return + } + if h.Len() < limit { + heap.Push(h, result) + return + } + if compareResultsForObjective(result, h.items[0], h.objective) <= 0 { + return + } + h.items[0] = result + heap.Fix(h, 0) +} + +func selectOptimizeResults(results []model.OptimizeResult, maxResults int, objectives []string) []model.OptimizeResult { + if len(results) == 0 { + return []model.OptimizeResult{} + } + if maxResults <= 0 { + maxResults = defaultMaxResults + } + if len(objectives) <= 1 { + out := append([]model.OptimizeResult(nil), results...) + sort.Slice(out, func(i, j int) bool { + if out[i].Score == out[j].Score { + return out[i].Key < out[j].Key + } + return out[i].Score > out[j].Score + }) + if len(out) > maxResults { + out = out[:maxResults] + } + return out + } + + selected := map[string]struct{}{} + out := make([]model.OptimizeResult, 0, maxResults) + addQuota := maxResults / len(objectives) + if addQuota < 1 { + addQuota = 1 + } + addTop := func(value func(model.OptimizeResult) float64) { + sorted := append([]model.OptimizeResult(nil), results...) + sort.Slice(sorted, func(i, j int) bool { + left := value(sorted[i]) + right := value(sorted[j]) + if left == right { + return sorted[i].Score > sorted[j].Score + } + return left > right + }) + limit := addQuota + if limit > len(sorted) { + limit = len(sorted) + } + added := 0 + for _, result := range sorted { + if len(out) >= maxResults || added >= limit { + return + } + if _, ok := selected[result.Key]; ok { + continue + } + selected[result.Key] = struct{}{} + out = append(out, result) + added++ + } + } + + for _, objective := range objectives { + currentObjective := objective + addTop(func(result model.OptimizeResult) float64 { + return resultObjectiveValue(result, currentObjective) + }) + } + + if len(selected) < maxResults { + sorted := append([]model.OptimizeResult(nil), results...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Score == sorted[j].Score { + return sorted[i].Key < sorted[j].Key + } + return sorted[i].Score > sorted[j].Score + }) + for _, result := range sorted { + if len(selected) >= maxResults { + break + } + if _, ok := selected[result.Key]; ok { + continue + } + selected[result.Key] = struct{}{} + out = append(out, result) + } + } + + if len(out) > maxResults { + out = out[:maxResults] + } + return out +} + +func findHeroByCode(heroes []model.OptimizeHero, code string) *model.OptimizeHero { + for i := range heroes { + if heroes[i].Code == code { + return &heroes[i] + } + } + return nil +} + +func buildBaseStats(hero *model.OptimizeHero) baseStats { + atk := hero.BaseAtk + if atk == 0 { + atk = hero.Atk + } + def := hero.BaseDef + if def == 0 { + def = hero.Def + } + hp := hero.BaseHp + if hp == 0 { + hp = hero.Hp + } + spd := hero.BaseSpd + if spd == 0 { + spd = hero.Spd + } + cr := hero.BaseCr + if cr == 0 { + cr = hero.Cr + } + cd := hero.BaseCd + if cd == 0 { + cd = hero.Cd + } + acc := hero.BaseEff + if acc == 0 { + acc = hero.Eff + } + res := hero.BaseRes + if res == 0 { + res = hero.Res + } + return baseStats{ + atk: atk, + def: def, + hp: hp, + spd: spd, + cr: cr, + cd: cd, + acc: acc, + res: res, + } +} + +func buildItemStats(item model.OptimizeItem) itemStats { + stats := itemStats{} + apply := func(stat *model.OptimizeStat) { + if stat == nil { + return + } + switch stat.Type { + case "Attack": + stats.atk += stat.Value + case "Defense": + stats.def += stat.Value + case "Health": + stats.hp += stat.Value + case "Speed": + stats.spd += stat.Value + case "CriticalHitChancePercent": + stats.cr += stat.Value + case "CriticalHitDamagePercent": + stats.cd += stat.Value + case "EffectivenessPercent": + stats.acc += stat.Value + case "EffectResistancePercent": + stats.res += stat.Value + case "AttackPercent": + stats.atkPct += stat.Value + case "DefensePercent": + stats.defPct += stat.Value + case "HealthPercent": + stats.hpPct += stat.Value + } + } + + if item.Main != nil { + apply(item.Main) + } + for i := range item.Substats { + stat := item.Substats[i] + apply(&stat) + } + + return stats +} + +func scoreCandidateItem(stats itemStats, base baseStats, req model.OptimizeRequest) float64 { + return scoreItemWeights(stats, base, req.WeightValues) +} + +func itemStatContribution(stats itemStats, base baseStats, key string) float64 { + switch key { + case "atk": + return stats.atk + base.atk*(stats.atkPct/100) + case "def": + return stats.def + base.def*(stats.defPct/100) + case "hp": + return stats.hp + base.hp*(stats.hpPct/100) + case "spd": + return stats.spd + case "cr": + return stats.cr + case "cd": + return stats.cd + case "acc": + return stats.acc + case "res": + return stats.res + default: + return 0 + } +} + +func scoreItemWeights(stats itemStats, base baseStats, weights map[string]float64) float64 { + if len(weights) == 0 { + return 0 + } + w := func(key string) float64 { + if val, ok := weights[key]; ok { + if val <= 0 { + return 0 + } + return val + } + return 0 + } + return (itemStatContribution(stats, base, "atk")/100)*w("atk") + + (itemStatContribution(stats, base, "def")/100)*w("def") + + (itemStatContribution(stats, base, "hp")/100)*w("hp") + + stats.spd*2*w("spd") + + stats.cr*w("cr") + + stats.cd*w("cd") + + stats.acc*w("acc") + + stats.res*w("res") +} + +type totalsStats struct { + atk float64 + def float64 + hp float64 + spd float64 + cr float64 + cd float64 + acc float64 + res float64 +} + +type setEffectCounts struct { + attack int + health int + defense int + speed int + revenge int + reversal int + critical int + hit int + resist int + destruction int + warfare int + torrent int +} + +func buildTotalStats(statsList []itemStats, base baseStats, bonus model.BonusStats, items []model.OptimizeItem) totalsStats { + var totals itemStats + for _, stats := range statsList { + 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 + } + + var counts setEffectCounts + for _, item := range items { + addSetEffectCount(&counts, item.Set) + } + + return calculateTotalStats(totals, base, bonus, counts) +} + +func buildTotalStatsForSearch(searchItemsByGear [][]searchSlotItem, indices [6]int, base baseStats, bonus model.BonusStats) totalsStats { + var totals itemStats + var counts setEffectCounts + for slot := 0; slot < len(searchItemsByGear); slot++ { + index := indices[slot] + if index < 0 || index >= len(searchItemsByGear[slot]) { + continue + } + candidate := searchItemsByGear[slot][index] + stats := candidate.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 + addSetEffectCount(&counts, candidate.item.Set) + } + return calculateTotalStats(totals, base, bonus, counts) +} + +func buildItemsListForSearch(searchItemsByGear [][]searchSlotItem, indices [6]int) []model.OptimizeItem { + itemsList := make([]model.OptimizeItem, 0, len(searchItemsByGear)) + for slot := 0; slot < len(searchItemsByGear); slot++ { + itemsList = append(itemsList, searchItemsByGear[slot][indices[slot]].item) + } + return itemsList +} + +func addSetEffectCount(counts *setEffectCounts, setName string) { + switch setName { + case "AttackSet": + counts.attack++ + case "HealthSet": + counts.health++ + case "DefenseSet": + counts.defense++ + case "SpeedSet": + counts.speed++ + case "RevengeSet": + counts.revenge++ + case "ReversalSet": + counts.reversal++ + case "CriticalSet": + counts.critical++ + case "HitSet": + counts.hit++ + case "ResistSet": + counts.resist++ + case "DestructionSet": + counts.destruction++ + case "WarfareSet": + counts.warfare++ + case "TorrentSet": + counts.torrent++ + } +} + +func calculateTotalStats(totals itemStats, base baseStats, bonus model.BonusStats, counts setEffectCounts) totalsStats { + bonusBaseAtk := base.atk + base.atk*(bonus.AtkPct/100) + bonus.Atk + bonusBaseDef := base.def + base.def*(bonus.DefPct/100) + bonus.Def + bonusBaseHp := base.hp + base.hp*(bonus.HpPct/100) + bonus.Hp + + bonusMaxAtk := 1 + bonus.FinalAtkMultiplier/100 + bonusMaxDef := 1 + bonus.FinalDefMultiplier/100 + bonusMaxHp := 1 + bonus.FinalHpMultiplier/100 + + attackSetBonus := 0.0 + if counts.attack >= 4 { + attackSetBonus = 0.45 * base.atk + } + healthSetBonus := float64(counts.health/2) * 0.20 * base.hp + defenseSetBonus := float64(counts.defense/2) * 0.20 * base.def + speedSetBonus := 0.0 + if counts.speed >= 4 { + speedSetBonus = 0.25 * base.spd + } + revengeSetBonus := 0.0 + if counts.revenge >= 4 { + revengeSetBonus = 0.12 * base.spd + } + reversalSetBonus := 0.0 + if counts.reversal >= 4 { + reversalSetBonus = 0.15 * base.spd + } + criticalSetBonus := float64(counts.critical/2) * 12 + hitSetBonus := float64(counts.hit/2) * 20 + resistSetBonus := float64(counts.resist/2) * 20 + destructionSetBonus := 0.0 + if counts.destruction >= 4 { + destructionSetBonus = 60 + } + warfareSetBonus := 0.0 + if counts.warfare >= 4 { + warfareSetBonus = 0.20 * base.hp + } + torrentSetPenalty := float64(counts.torrent/2) * (-0.10 * base.hp) + + atkFlat := totals.atk + base.atk*(totals.atkPct/100) + defFlat := totals.def + base.def*(totals.defPct/100) + hpFlat := totals.hp + base.hp*(totals.hpPct/100) + + atk := (bonusBaseAtk + atkFlat + attackSetBonus) * bonusMaxAtk + def := (bonusBaseDef + defFlat + defenseSetBonus) * bonusMaxDef + hp := (bonusBaseHp + hpFlat + healthSetBonus + warfareSetBonus + torrentSetPenalty) * bonusMaxHp + spd := base.spd + totals.spd + speedSetBonus + revengeSetBonus + reversalSetBonus + bonus.Spd + cr := math.Min(100, base.cr+totals.cr+criticalSetBonus+bonus.Cr) + cd := math.Min(350, base.cd+totals.cd+destructionSetBonus+bonus.Cd) + acc := base.acc + totals.acc + hitSetBonus + bonus.Eff + res := base.res + totals.res + resistSetBonus + bonus.Res + + return totalsStats{ + atk: atk, + def: def, + hp: hp, + spd: spd, + cr: cr, + cd: cd, + acc: acc, + res: res, + } +} + +func panelWholeStat(value float64) float64 { + return math.Floor(value) +} + +func panelRoundedStat(value float64) float64 { + return math.Round(value) +} + +func passesFilters(t totalsStats, filters map[string]model.StatRange) bool { + if len(filters) == 0 { + return true + } + for key, rangeVal := range filters { + var value float64 + switch key { + case "atk": + value = panelWholeStat(t.atk) + case "def": + value = panelWholeStat(t.def) + case "hp": + value = panelWholeStat(t.hp) + case "spd": + value = panelRoundedStat(t.spd) + case "cr": + value = panelRoundedStat(t.cr) + case "cd": + value = panelRoundedStat(t.cd) + case "acc": + value = panelRoundedStat(t.acc) + case "res": + value = panelRoundedStat(t.res) + default: + continue + } + if rangeVal.Min != nil && value < *rangeVal.Min { + return false + } + if rangeVal.Max != nil && value > *rangeVal.Max { + return false + } + } + return true +} + +var ingameSetMap = map[string]string{ + "set_acc": "HitSet", + "set_att": "AttackSet", + "set_coop": "UnitySet", + "set_counter": "CounterSet", + "set_cri_dmg": "DestructionSet", + "set_cri": "CriticalSet", + "set_def": "DefenseSet", + "set_immune": "ImmunitySet", + "set_max_hp": "HealthSet", + "set_penetrate": "PenetrationSet", + "set_rage": "RageSet", + "set_res": "ResistSet", + "set_revenge": "RevengeSet", + "set_scar": "InjurySet", + "set_speed": "SpeedSet", + "set_vampire": "LifestealSet", + "set_shield": "ProtectionSet", + "set_torrent": "TorrentSet", + "set_revenant": "ReversalSet", + "set_riposte": "RiposteSet", + "set_chase": "PursuitSet", + "set_opener": "WarfareSet", +} + +func itemPassesMainStatFilter(item model.OptimizeItem, filters map[string]string) bool { + if len(filters) == 0 { + return true + } + var key string + switch item.Gear { + case "Necklace": + key = "necklace" + case "Ring": + key = "ring" + case "Boots": + key = "boots" + default: + return true + } + want, ok := filters[key] + if !ok || want == "" || want == "All" { + return true + } + return item.Main != nil && item.Main.Type == want +} + +func passesMainStatFilter(items []model.OptimizeItem, filters map[string]string) bool { + if len(filters) == 0 { + return true + } + check := func(gear string, key string) bool { + want, ok := filters[key] + if !ok || want == "" || want == "All" { + return true + } + for _, item := range items { + if item.Gear != gear { + continue + } + if item.Main == nil || item.Main.Type == "" { + return false + } + return item.Main.Type == want + } + return true + } + + if !check("Necklace", "necklace") { + return false + } + if !check("Ring", "ring") { + return false + } + if !check("Boots", "boots") { + return false + } + return true +} + +func passesSetFilter(items []model.OptimizeItem, filters model.SetFilters) bool { + requiredCounts, _, ok := buildSetRequirements(filters) + if !ok { + return false + } + if len(requiredCounts) == 0 { + return true + } + counts := map[string]int{} + for _, item := range items { + if item.Set == "" { + continue + } + counts[item.Set]++ + } + + for setName, requiredPieces := range requiredCounts { + if counts[setName] < requiredPieces { + return false + } + } + + return true +} + +func buildSetRequirements(filters model.SetFilters) (map[string]int, int, bool) { + required := map[string]int{} + totalPieces := 0 + + for _, group := range [][]string{filters.Set1, filters.Set2, filters.Set3} { + for _, setName := range group { + if setName == "" || setName == "All" { + continue + } + pieces := setPieceCount(setName) + if pieces == 0 { + return required, totalPieces, false + } + totalPieces += pieces + if totalPieces > len(gearSlots) { + return required, totalPieces, false + } + required[setName] += pieces + } + } + + return required, totalPieces, true +} + +func setPieceCount(setName string) int { + if isFourPieceSet(setName) { + return 4 + } + if isTwoPieceSet(setName) { + return 2 + } + return 0 +} + +func scoreTotals(t totalsStats, weights map[string]float64) float64 { + if len(weights) == 0 { + return 0 + } + w := func(key string) float64 { + if val, ok := weights[key]; ok { + if val <= 0 { + return 0 + } + return val / 3 + } + return 0 + } + wAtk := w("atk") + wDef := w("def") + wHp := w("hp") + wSpd := w("spd") + wCr := w("cr") + wCd := w("cd") + wAcc := w("acc") + wRes := w("res") + + return (t.atk/100)*wAtk + + (t.def/100)*wDef + + (t.hp/100)*wHp + + t.spd*2*wSpd + + t.cr*wCr + + t.cd*wCd + + t.acc*wAcc + + t.res*wRes +} + +func getCompletedSets(items []model.OptimizeItem) []string { + counts := map[string]int{} + for _, item := range items { + if item.Set == "" { + continue + } + counts[item.Set]++ + } + + var completed []string + for setName, count := range counts { + if isFourPieceSet(setName) && count >= 4 { + completed = append(completed, setName) + continue + } + if isTwoPieceSet(setName) && count >= 2 { + stacks := count / 2 + for i := 0; i < stacks; i++ { + completed = append(completed, setName) + } + } + } + return completed +} + +func isFourPieceSet(setName string) bool { + return fourPieceSets[setName] +} + +func isTwoPieceSet(setName string) bool { + return twoPieceSets[setName] +} + +func joinSets(sets []string) string { + if len(sets) == 0 { + return "" + } + out := sets[0] + for i := 1; i < len(sets); i++ { + out += "/" + sets[i] + } + return out +} + +func itemKey(item model.OptimizeItem) string { + if item.ID == nil { + return fmt.Sprintf("%s-%s", item.Gear, item.Set) + } + return fmt.Sprint(item.ID) +} + +func buildKey(items []model.OptimizeItem) string { + key := "" + for i, item := range items { + if i > 0 { + key += "-" + } + key += itemKey(item) + } + return key +} diff --git a/internal/service/database_service.go b/internal/service/database_service.go index 79721ae..5292a02 100644 --- a/internal/service/database_service.go +++ b/internal/service/database_service.go @@ -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 +} diff --git a/internal/service/main_service.go b/internal/service/main_service.go index 7322e7e..0479bc8 100644 --- a/internal/service/main_service.go +++ b/internal/service/main_service.go @@ -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