2116 lines
50 KiB
Go
2116 lines
50 KiB
Go
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
|
|
}
|