package dnfbridge import ( "context" "fmt" "math/rand/v2" "sort" "strconv" "sync" dnfpvf "longheng.io/server/internal/modules/dnf/pvf" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // Only ordinary combat equipment is randomized. Titles, avatars (including // clone/dye/socket state), creatures and cosmetic slots retain source identity. func currentCompanionRandomEquipmentSlot(slot uint64) bool { return slot == 12 || (slot >= 14 && slot <= 25) } type currentCompanionEquipmentCandidate struct { id uint32 path string slot int16 level int rarity int64 equipmentType int64 durability uint16 jobs []string requiredSkills []int64 requiredJobSkills map[string]int64 } type currentCompanionEquipmentPool struct { once sync.Once items []currentCompanionEquipmentCandidate err error } // Match the existing per-catalog PVF pool lifetime; no random choice is cached. var currentCompanionEquipmentPools sync.Map func loadCurrentCompanionEquipmentPool(catalog *pvfDungeonDropCatalog) ([]currentCompanionEquipmentCandidate, error) { if catalog == nil || catalog.source == nil { return nil, errDungeonDropSourceRequired } entry, _ := currentCompanionEquipmentPools.LoadOrStore(catalog, ¤tCompanionEquipmentPool{}) pool := entry.(*currentCompanionEquipmentPool) pool.once.Do(func() { for _, id := range catalog.equipmentIDs { ref := catalog.itemRefs[id] if ref.kind != dungeonDropItemEquipment { continue } path, text, err := readDungeonDropText(catalog.source, string(dungeonDropItemEquipment), ref.path) if err != nil { pool.err = fmt.Errorf("companion equipment %d: %w", id, err) return } doc, err := dnfpvf.Parse(path, text) if err != nil { pool.err = fmt.Errorf("companion equipment %d: %w", id, err) return } rarity, found := doc.Int("rarity") if !found || rarity < 1 || rarity > 3 { continue } // 1=blue, 2=purple, 3=pink in the current PVF. typ, _ := doc.Text("equipment type") rule, ok := currentEquipmentPlacementRuleForPVFType(typ) if !ok || !currentCompanionRandomEquipmentSlot(uint64(rule.targetSlot)) { continue } level, found := doc.Int("minimum level") if !found || level < 1 || level > 90 { continue } // Timed/event rentals are not permanent combat equipment templates. period, _ := doc.Int("usable period") if period > 0 || len(doc.Texts("expiration date")) > 0 { continue } equipType, _ := doc.Int("equipment type") durability, _ := doc.Int("durability") if durability < 0 || durability > 65535 { continue } item := currentCompanionEquipmentCandidate{id: id, path: path, slot: rule.targetSlot, level: int(level), rarity: rarity, equipmentType: equipType, durability: uint16(durability), jobs: doc.Texts("usable job"), requiredSkills: doc.Ints("required skill")} valid := true for _, section := range doc.Sections { if currentRentalSectionName(section.Name) != "required job skill" { continue } tokens, ok := currentRentalSectionTokens(doc, section) if !ok || len(tokens) == 0 || len(tokens)%2 != 0 { valid = false break } item.requiredJobSkills = make(map[string]int64) for i := 0; i < len(tokens); i += 2 { if (tokens[i].Kind != dnfpvf.TokenString && tokens[i].Kind != dnfpvf.TokenIdent) || tokens[i+1].Kind != dnfpvf.TokenInt || tokens[i+1].Int <= 0 { valid = false break } item.requiredJobSkills[currentRentalJobTag(tokens[i].Value)] = tokens[i+1].Int } } if valid { pool.items = append(pool.items, item) } } sort.Slice(pool.items, func(i, j int) bool { return pool.items[i].id < pool.items[j].id }) }) return pool.items, pool.err } func (item currentCompanionEquipmentCandidate) usable(job string, level int, skills dnfrepo.SkillRecord) bool { if item.level > level { return false } allowed := len(item.jobs) == 0 && item.slot != 12 for _, raw := range item.jobs { tag := currentRentalJobTag(raw) if tag == job || tag == "[all]" || tag == "all" { allowed = true } } if !allowed { return false } hasSkill := func(id int64) bool { skill, ok := skills.Skills[id]; return ok && skill.Enabled && skill.Level > 0 } for _, id := range item.requiredSkills { if !hasSkill(id) { return false } } if item.requiredJobSkills != nil { id, ok := item.requiredJobSkills[job] if !ok || !hasSkill(id) { return false } } return true } func buildCurrentCompanionEquipment(catalog *pvfDungeonDropCatalog, state dnfrepo.CompanionSourceState, equipmentLevel int, pick func(int) int, allowRarity func(int64) bool, keyPrefix, missingWeaponLabel string) (dnfrepo.EquipmentRecord, error) { if catalog == nil || catalog.source == nil { return dnfrepo.EquipmentRecord{}, errDungeonDropSourceRequired } if !state.HasEquipment || !state.HasSkill || state.Equipment.CharacterID != state.Source.CharacterID || state.Skill.CharacterID != state.Source.CharacterID || equipmentLevel < 1 || equipmentLevel > 90 { return dnfrepo.EquipmentRecord{}, fmt.Errorf("companion equipment source/level unavailable") } job, ok := characterJobByte(state.Source) if !ok { return dnfrepo.EquipmentRecord{}, fmt.Errorf("companion equipment job=%s", state.Source.Job) } jobTag, err := currentRentalCharacterJobTag(catalog.source, job) if err != nil { return dnfrepo.EquipmentRecord{}, err } pool, err := loadCurrentCompanionEquipmentPool(catalog) if err != nil { return dnfrepo.EquipmentRecord{}, err } bySlot := make(map[int16][]currentCompanionEquipmentCandidate) for _, item := range pool { if !allowRarity(item.rarity) { continue } if !item.usable(jobTag, equipmentLevel, state.Skill) { continue } current := bySlot[item.slot] if len(current) == 0 || item.level > current[0].level { bySlot[item.slot] = []currentCompanionEquipmentCandidate{item} } else if item.level == current[0].level { bySlot[item.slot] = append(current, item) } } if len(bySlot[12]) == 0 { return dnfrepo.EquipmentRecord{}, fmt.Errorf("支援兵职业 %s 在 %d 级以内没有可用%s武器", jobTag, equipmentLevel, missingWeaponLabel) } out := dnfrepo.CloneEquipment(state.Equipment) for key, entry := range out.Entries { slot, known := currentEXEActorEquipmentSlot(entry) if !known { return dnfrepo.EquipmentRecord{}, fmt.Errorf("companion source equipment slot=%d unsupported", entry.SlotIndex) } if currentCompanionRandomEquipmentSlot(slot) { delete(out.Entries, key) } } if out.Entries == nil { out.Entries = make(map[string]dnfrepo.EquipmentEntry) } for slot := int16(12); slot <= 25; slot++ { items := bySlot[slot] if len(items) == 0 { // Special slots stay empty until eligible gear exists at this level. continue } item := items[pick(len(items))] // Map keys in legacy snapshots are not always slot numbers. Avoid // replacing a retained avatar whose storage key happens to be numeric. key := keyPrefix + strconv.Itoa(int(slot)) out.Entries[key] = dnfrepo.EquipmentEntry{SlotIndex: slot, ItemID: int64(item.id), Extra: map[string]string{"item_kind": "equipment", "pvf_path": item.path, "equipment_type": strconv.FormatInt(item.equipmentType, 10), "current_exe_equipment_type": strconv.Itoa(int(slot)), "durability": strconv.Itoa(int(item.durability)), "max_durability": strconv.Itoa(int(item.durability))}} } return out, nil } func buildCurrentCompanionBalancedEquipment(catalog *pvfDungeonDropCatalog, state dnfrepo.CompanionSourceState, pick func(int) int) (dnfrepo.EquipmentRecord, error) { return buildCurrentCompanionEquipment(catalog, state, state.Owner.Level, pick, func(rarity int64) bool { return rarity == 3 }, "companion-balanced-", "粉色") } func buildCurrentCompanionRandomEquipment(catalog *pvfDungeonDropCatalog, state dnfrepo.CompanionSourceState, pick func(int) int) (dnfrepo.EquipmentRecord, error) { // Random gear follows the support soldier's own level. Balance mode keeps // its existing owner-level behavior in buildCurrentCompanionBalancedEquipment. return buildCurrentCompanionEquipment(catalog, state, state.Source.Level, pick, func(rarity int64) bool { return rarity >= 1 && rarity <= 3 }, "companion-random-", "蓝/紫/粉色") } func (s *Service) currentCompanionBalancedEquipment(ctx context.Context, state dnfrepo.CompanionSourceState) (dnfrepo.EquipmentRecord, error) { if err := ctx.Err(); err != nil { return dnfrepo.EquipmentRecord{}, err } catalog, err := s.currentPVFItemCatalog() if err != nil { return dnfrepo.EquipmentRecord{}, err } return buildCurrentCompanionBalancedEquipment(catalog, state, rand.IntN) } func (s *Service) currentCompanionRandomEquipment(ctx context.Context, state dnfrepo.CompanionSourceState) (dnfrepo.EquipmentRecord, error) { if err := ctx.Err(); err != nil { return dnfrepo.EquipmentRecord{}, err } catalog, err := s.currentPVFItemCatalog() if err != nil { return dnfrepo.EquipmentRecord{}, err } return buildCurrentCompanionRandomEquipment(catalog, state, rand.IntN) }