package dnfbridge import ( "context" "errors" "fmt" "strconv" "strings" "time" "longheng.io/server/internal/modules/dnf/alignedcmd" dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // 本文件补齐 GmTool90CN 剩下的三类写入能力: // // POST /local/dnf-gm/item/grant-options 带强化/增幅/锻造/品质/耐久的发放 // POST /local/dnf-gm/item/delete 支持 collection 切换(背包 / 穿戴装备) // POST /local/dnf-gm/pet/update 宠物等级 / 经验 / 饱食度 // POST /local/dnf-gm/inventory/clear 清空背包(保留受保护的钱包槽) // // 装备属性的构造复用 GMSendItemMailWithOptions 那套已验证的 extra 键 // (reinforce/ext_data0、amplify_type/byte_13/value_13、forging、durability…), // 不另外发明一份,否则邮件发放与直接发放会写出两种形态的装备。 var ( // ErrGMInventoryProtectedSlot 表示目标槽位是受保护的固定钱包槽。 ErrGMInventoryProtectedSlot = errors.New("gm inventory slot is protected") // ErrGMPetNotFound 表示角色没有该宠物。 ErrGMPetNotFound = errors.New("gm pet not found") ) // GMItemGrantOptionsRequest 对应 POST /local/dnf-gm/item/grant-options。 type GMItemGrantOptionsRequest struct { CharacterID string `json:"character_id"` ItemID uint32 `json:"item_id"` Count uint32 `json:"count,omitempty"` Reinforce int `json:"reinforce,omitempty"` AmplifyType int `json:"amplify_type,omitempty"` Forging int `json:"forging,omitempty"` EquipmentQuality string `json:"equipment_quality,omitempty"` Durability *int `json:"durability,omitempty"` ExpireDays int `json:"expire_days,omitempty"` EmblemItemIDs []uint32 `json:"emblem_item_ids,omitempty"` EnchantBeadID uint32 `json:"enchant_bead_item_id,omitempty"` } // GMItemGrantOptionsResult 描述带属性发放的落点。 type GMItemGrantOptionsResult struct { CharacterID string `json:"character_id"` ItemID uint32 `json:"item_id"` Count uint32 `json:"count"` ListType byte `json:"list_type"` Slot int `json:"slot"` EntryKey string `json:"entry_key"` ItemName string `json:"item_name,omitempty"` PVFPath string `json:"pvf_path,omitempty"` IsEquipment bool `json:"is_equipment"` Extra map[string]string `json:"extra,omitempty"` } // GMGrantItemWithOptions 发放一件装备(带强化/增幅/锻造/品质/耐久/附魔/徽章)。 // // 走副本拾取那套 placement 决定落点;属性由 gmBuildGrantedItemStack 统一构造, // 与邮件发放使用同一份键集合。 func (s *Service) GMGrantItemWithOptions( ctx context.Context, request GMItemGrantOptionsRequest, ) (GMItemGrantOptionsResult, error) { result := GMItemGrantOptionsResult{} if s == nil { return result, ErrGMItemGrantUnavailable } if ctx == nil { ctx = context.Background() } characterID := strings.TrimSpace(request.CharacterID) if characterID == "" { return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid) } if request.ItemID == 0 { return result, fmt.Errorf("%w: item_id is required", ErrGMRequestInvalid) } count := request.Count if count == 0 { count = gmItemDefaultCount } result.CharacterID = characterID result.ItemID = request.ItemID // 与邮件发放相同的取值范围校验,避免写出客户端读不了的装备。 if request.Reinforce < 0 || request.Reinforce > 31 || request.AmplifyType < 0 || request.AmplifyType > 4 || request.Forging < 0 || request.Forging > 8 || request.ExpireDays < 0 || request.ExpireDays > 3650 { return result, fmt.Errorf("%w: reinforce/amplify/forging/expire_days out of range", ErrGMRequestInvalid) } catalog, err := s.currentPVFItemCatalog() if err != nil { return result, fmt.Errorf("%w: %v", ErrGMItemGrantUnavailable, err) } definition, err := catalog.ResolveItem(request.ItemID) if err != nil { return result, fmt.Errorf("%w: item=%d resolved=false", ErrGMItemNotFound, request.ItemID) } result.ItemName = definition.Name result.PVFPath = definition.PVFPath isEquipment := definition.Kind == dungeonDropItemEquipment result.IsEquipment = isEquipment if isEquipment && count != 1 { count = 1 } if definition.StackLimit > 0 && int64(count) > definition.StackLimit { count = uint32(definition.StackLimit) } stack, buildErr := s.buildGMGrantItemStack(ctx, catalog, definition, count, gmGrantItemOptions{ Reinforce: request.Reinforce, AmplifyType: request.AmplifyType, Forging: request.Forging, EquipmentQuality: request.EquipmentQuality, Durability: request.Durability, ExpireDays: request.ExpireDays, EmblemItemIDs: request.EmblemItemIDs, EnchantBeadID: request.EnchantBeadID, }) if buildErr != nil { return result, buildErr } result.Extra = stack.Extra result.Count = uint32(stack.Count) placement := currentDungeonPickupPlacement(definition, count) // 用构造好的行覆盖 placement 默认构造,保证属性不丢。 placement.BuildNew = func(int16) (dnfrepo.ItemStack, error) { return gmCloneStack(stack), nil } placement.NormalizeExisting = nil repositories, available := s.repositoryGroup() if !available { return result, ErrGMRepositoryUnavailable } owner, err := dnfdungeon.NewOwner(repositories) if err != nil { return result, fmt.Errorf("%w: %v", ErrGMItemGrantUnavailable, err) } grant, err := owner.GrantPickupItem(ctx, dnfdungeon.PickupItemCommand{ CharacterID: characterID, Placement: placement, UpdatedAt: time.Now().UTC(), }) if err != nil { return result, err } result.ListType = placement.ListType result.Slot = int(grant.Slot) result.EntryKey = fmt.Sprintf("%d:%d", placement.ListType, grant.Slot) result.Count = uint32(grant.Stack.Count) // 在线角色补发 op14 增量物品行,让带属性发放的装备即时出现在客户端背包里, // 不必回到角色选择界面重拉全量快照。离线角色下次选角色时走 bootstrap 全量刷新。 if _, numericID, idErr := gmCharacterID(characterID); idErr == nil { if session, online := s.onlineGameSession(numericID); online { if pushErr := s.sendSelectedIncrementalItemSlotRefreshes( session, "gm-item-grant-options", []alignedcmd.ItemSlotRefresh{{ListType: placement.ListType, SlotIndex: int16(grant.Slot)}}, ); pushErr != nil { s.logWarn("dnf90 GM item grant-options live refresh failed", "character_id", characterID, "entry_key", result.EntryKey, "error", pushErr) } } } return result, nil } // gmGrantItemOptions 是发放时可选属性的内部视图。 type gmGrantItemOptions struct { Reinforce int AmplifyType int Forging int EquipmentQuality string Durability *int ExpireDays int EmblemItemIDs []uint32 EnchantBeadID uint32 } // buildGMGrantItemStack 构造一件带属性的待发放道具行。 // // extra 键集合与 GMSendItemMailWithOptions 完全一致 —— 两条发放路径必须写出 // 同一种形态的装备,否则"邮件发的能用、直接发的用不了"这类问题会反复出现。 func (s *Service) buildGMGrantItemStack( ctx context.Context, catalog *pvfDungeonDropCatalog, definition dungeonDropItemDefinition, count uint32, options gmGrantItemOptions, ) (dnfrepo.ItemStack, error) { if catalog == nil { return dnfrepo.ItemStack{}, ErrGMItemGrantUnavailable } now := time.Now().UTC() stack := dnfrepo.ItemStack{ ItemID: int64(definition.ItemID), Count: int64(count), Extra: map[string]string{ "source": "gm_tool_grant", "granted_at": now.Format(time.RFC3339), "item_kind": string(definition.Kind), "pvf_path": definition.PVFPath, "stackable_type": definition.StackableType, }, } if definition.StackLimit > 0 { stack.Extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10) } // PVF 自带的有效期先落到行上。 stack, _ = applyCurrentPVFItemExpirationAt(stack, definition, now) if options.ExpireDays > 0 { stack, _ = applyCurrentGMExpirationOverrideAt( stack, now.Add(time.Duration(options.ExpireDays)*24*time.Hour), options.ExpireDays, now, ) } if definition.Kind != dungeonDropItemEquipment { if options.Reinforce != 0 || options.AmplifyType != 0 || options.Forging != 0 || len(options.EmblemItemIDs) != 0 || options.EnchantBeadID != 0 { return dnfrepo.ItemStack{}, fmt.Errorf("%w: equipment attributes require an equipment item", ErrGMRequestInvalid) } return stack, nil } if definition.EquipmentType != "" { stack.Extra["equipment_type"] = definition.EquipmentType } gmApplyMailEquipmentSeal(&stack, definition) // 时装类装备进装扮容器,与拾取/邮件判定保持一致。 quality := strings.ToLower(strings.TrimSpace(options.EquipmentQuality)) if !gmItemSupportsEquipmentAttributes(definition.EquipmentType) && (options.Reinforce != 0 || options.AmplifyType != 0 || (quality != "" && quality != "default")) { return dnfrepo.ItemStack{}, fmt.Errorf("%w: equipment attributes are not supported by this equipment type", ErrGMRequestInvalid) } durability := int64(definition.Durability) if options.Durability != nil { if *options.Durability < 0 { return dnfrepo.ItemStack{}, fmt.Errorf("%w: durability must not be negative", ErrGMRequestInvalid) } durability = int64(*options.Durability) } if durability > 0 { text := strconv.FormatInt(durability, 10) stack.Extra["durability"] = text stack.Extra["max_durability"] = strconv.FormatInt(int64(definition.Durability), 10) } if options.Reinforce > 0 { value := strconv.Itoa(options.Reinforce) stack.Extra["reinforce"] = value stack.Extra["ext_data0"] = value } if options.Forging > 0 { stack.Extra["forging"] = strconv.Itoa(options.Forging) } if options.AmplifyType > 0 { // 增幅必须同时写"选项"和它的 PVF 基值:只写选项会让客户端在 op80 // 走到 "option present but no value" 分支并回错误 174。 config, configErr := currentAmplifyItemConfigFromSource(catalog.source) if configErr != nil { return dnfrepo.ItemStack{}, fmt.Errorf("resolve amplification config: %w", configErr) } initialValues, initialErr := currentAmplifyInitialValues(config, definition.Rarity) if initialErr != nil { return dnfrepo.ItemStack{}, fmt.Errorf("resolve amplification value: %w", initialErr) } amplifyValue := initialValues[byte(options.AmplifyType)] if amplifyValue == 0 { return dnfrepo.ItemStack{}, fmt.Errorf( "%w: item=%d amplification type=%d has no PVF value", ErrGMRequestInvalid, definition.ItemID, options.AmplifyType) } value := strconv.Itoa(options.AmplifyType) amplifyText := strconv.FormatUint(uint64(amplifyValue), 10) for _, key := range []string{"amplify_type", "amplification_type", "byte_13", "value_13", "value_c"} { stack.Extra[key] = value } stack.Extra["amplify_value"] = amplifyText stack.Extra["value_14"] = amplifyText stack.Extra["value_15"] = amplifyText } if definition.CreationRate == 0 { stack.Extra["creation_rate"] = "0" } // 与服务端权威路径(socket_emblem_projection.currentApplyEquipmentEmblemDataToStack) // 一样收尾:由 Extra 序列化出 0x77 的 raw_entry。 // // 关键:**必须走 currentRefreshStackRawEntry**,它内部用 // currentItemListEntryFromStack 把 Extra 的 value_a / byte_12 等键真正写进 // 原始行(raw[0x0E:0x12] 附魔卡片 ID、raw[0x12] 升级次数)。 // 早先这里直接手搓 currentItemListEntryFromStack 得到的是"只复制 raw"的行, // 附魔在服务端 Extra 里对、客户端却读不到。 currentRefreshStackRawEntry(&stack, dnfrepo.MainInventoryListType, 0) return stack, nil } // GMItemDeleteRequestV2 对应 POST /local/dnf-gm/item/delete。 // // Collection 取 "slots"(背包,默认)或 "wear"(穿戴装备), // 决定删除的是 inventory 行还是 equipment 行。 type GMItemDeleteRequestV2 struct { CharacterID string `json:"character_id"` Collection string `json:"collection,omitempty"` ListType *int `json:"list_type,omitempty"` Slot *int `json:"slot,omitempty"` EntryKey string `json:"entry_key,omitempty"` } // GMItemDeleteResultV2 描述删除结果。 type GMItemDeleteResultV2 struct { CharacterID string `json:"character_id"` Collection string `json:"collection"` EntryKey string `json:"entry_key"` ItemID int64 `json:"item_id"` Removed int64 `json:"removed"` } // GMDeleteItemV2 删除背包行或穿戴装备行。 func (s *Service) GMDeleteItemV2(ctx context.Context, request GMItemDeleteRequestV2) (GMItemDeleteResultV2, error) { result := GMItemDeleteResultV2{} if s == nil { return result, ErrGMItemGrantUnavailable } if ctx == nil { ctx = context.Background() } characterID := strings.TrimSpace(request.CharacterID) if characterID == "" { return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid) } result.CharacterID = characterID collection := strings.ToLower(strings.TrimSpace(request.Collection)) if collection == "" { collection = "slots" } result.Collection = collection worn := collection == "wear" || collection == "equipment" entryKey := strings.TrimSpace(request.EntryKey) if entryKey == "" { if request.ListType == nil || request.Slot == nil { return result, fmt.Errorf("%w: entry_key or (list_type, slot) is required", ErrGMRequestInvalid) } entryKey = fmt.Sprintf("%d:%d", *request.ListType, *request.Slot) } // 穿戴行的键就是槽位号,允许带 "wear:" 前缀。 entryKey = strings.TrimPrefix(entryKey, "wear:") result.EntryKey = entryKey if !worn { listType := int64(-1) slot := int64(-1) if parts := strings.SplitN(entryKey, ":", 2); len(parts) == 2 { listType, _ = strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) slot, _ = strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) } // 主背包 0:0~0:2 是固定钱包槽(金币/复活币等),不允许直接删。 if listType == int64(dnfrepo.MainInventoryListType) && slot >= 0 && slot <= 2 { return result, fmt.Errorf("%w: %s", ErrGMInventoryProtectedSlot, entryKey) } } repositories, available := s.repositoryGroup() if !available || repositories.CharacterSettlement == nil { return result, ErrGMRepositoryUnavailable } err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error { now := time.Now().UTC() if worn { if tx.Equipment == nil { return ErrGMRepositoryUnavailable } record, found, loadErr := tx.Equipment.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return fmt.Errorf("%w: equipment entry=%s", ErrGMItemRowNotFound, entryKey) } record = dnfrepo.CloneEquipment(record) entry, present := record.Entries[entryKey] if !present { return fmt.Errorf("%w: equipment entry=%s", ErrGMItemRowNotFound, entryKey) } result.ItemID = entry.ItemID result.Removed = 1 delete(record.Entries, entryKey) record.UpdatedAt = now return tx.Equipment.Save(ctx, record) } if tx.Inventory == nil { return ErrGMRepositoryUnavailable } record, found, loadErr := tx.Inventory.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID) } record = dnfrepo.CloneInventory(record) stack, present := record.Slots[entryKey] if !present { return fmt.Errorf("%w: entry_key=%s", ErrGMItemRowNotFound, entryKey) } result.ItemID = stack.ItemID result.Removed = stack.Count delete(record.Slots, entryKey) record.UpdatedAt = now return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, record, dnfrepo.InventoryFieldSlots) }) if err != nil { return result, err } return result, nil } // GMPetUpdateRequest 对应 POST /local/dnf-gm/pet/update。 // // PetKey 定位宠物;只传需要改的字段(nil 表示不改)。 type GMPetUpdateRequest struct { CharacterID string `json:"character_id"` PetKey string `json:"pet_key,omitempty"` Level *int64 `json:"level,omitempty"` Exp *int64 `json:"exp,omitempty"` Satiety *int64 `json:"satiety,omitempty"` ClearSatiety bool `json:"clear_satiety,omitempty"` PetIndex *int `json:"pet_index,omitempty"` } // GMPetUpdateResult 描述宠物更新结果。 type GMPetUpdateResult struct { CharacterID string `json:"character_id"` PetKey string `json:"pet_key"` Level int64 `json:"level"` Exp int64 `json:"exp"` Satiety int64 `json:"satiety"` Previous struct { Level int64 `json:"level"` Exp int64 `json:"exp"` Satiety int64 `json:"satiety"` } `json:"previous"` } // GMUpdatePet 修改宠物等级 / 经验 / 饱食度。 // // pet_key 省略时按 pet_index 取第 N 只(0 起,按 key 排序保证确定性); // 两者都给或都不给都会报错,避免改错目标。 func (s *Service) GMUpdatePet(ctx context.Context, request GMPetUpdateRequest) (GMPetUpdateResult, error) { result := GMPetUpdateResult{} if s == nil { return result, ErrGMRepositoryUnavailable } if ctx == nil { ctx = context.Background() } characterID := strings.TrimSpace(request.CharacterID) if characterID == "" { return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid) } if request.Level == nil && request.Exp == nil && request.Satiety == nil && !request.ClearSatiety { return result, fmt.Errorf("%w: level, exp or satiety is required", ErrGMRequestInvalid) } if request.Level != nil && (*request.Level < 1 || *request.Level > 9999) { return result, fmt.Errorf("%w: level out of range", ErrGMRequestInvalid) } if request.Exp != nil && *request.Exp < 0 { return result, fmt.Errorf("%w: exp must not be negative", ErrGMRequestInvalid) } if request.Satiety != nil && (*request.Satiety < 0 || *request.Satiety > 255) { return result, fmt.Errorf("%w: satiety must be 0..255", ErrGMRequestInvalid) } result.CharacterID = characterID petKey := strings.TrimSpace(request.PetKey) if petKey == "" && request.PetIndex == nil { return result, fmt.Errorf("%w: pet_key or pet_index is required", ErrGMRequestInvalid) } if petKey != "" && request.PetIndex != nil { return result, fmt.Errorf("%w: pet_key and pet_index are mutually exclusive", ErrGMRequestInvalid) } repositories, available := s.repositoryGroup() if !available { return result, ErrGMRepositoryUnavailable } // 宠物仓储只在 CharacterPets 事务里交付(CharacterSettlement 的 Group // 不带 Pet)。Hatch/List 走的也是这条包装器。 if repositories.CharacterPets == nil { return result, fmt.Errorf("%w: character pet transaction is not installed", ErrGMRepositoryUnavailable) } err := repositories.CharacterPets.WithinCharacterPets(ctx, characterID, func( _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository, pets dnfrepo.PetRepository, ) error { if pets == nil { return fmt.Errorf("%w: pet repository is unavailable inside the pet transaction", ErrGMRepositoryUnavailable) } record, found, loadErr := pets.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found || len(record.Entries) == 0 { return ErrGMPetNotFound } record = dnfrepo.ClonePet(record) if petKey == "" { keys := make([]string, 0, len(record.Entries)) for k := range record.Entries { keys = append(keys, k) } sortStrings(keys) index := *request.PetIndex if index < 0 || index >= len(keys) { return fmt.Errorf("%w: pet_index=%d total=%d", ErrGMPetNotFound, index, len(keys)) } petKey = keys[index] } entry, present := record.Entries[petKey] if !present { return fmt.Errorf("%w: pet_key=%s", ErrGMPetNotFound, petKey) } result.PetKey = petKey result.Previous.Level = entry.Level result.Previous.Exp = entry.Exp result.Previous.Satiety = int64(entry.Satiety) if request.Level != nil { entry.Level = *request.Level } if request.Exp != nil { entry.Exp = *request.Exp } if request.ClearSatiety { entry.Satiety = 0 entry.SatietyMicros = 0 } else if request.Satiety != nil { entry.Satiety = byte(*request.Satiety) // 手动改饱食度时把服务端持有的分数刻度清零, // 否则下次 tick 会按旧刻度补回半格。 entry.SatietyMicros = 0 } record.Entries[petKey] = entry record.UpdatedAt = time.Now().UTC() if saveErr := dnfrepo.SavePetFields(ctx, pets, record, dnfrepo.PetFieldEntries); saveErr != nil { return saveErr } result.Level = entry.Level result.Exp = entry.Exp result.Satiety = int64(entry.Satiety) return nil }) if err != nil { return result, err } return result, nil } // GMInventoryClearRequest 对应 POST /local/dnf-gm/inventory/clear。 type GMInventoryClearRequest struct { CharacterID string `json:"character_id"` Collection string `json:"collection,omitempty"` KeepWallet *bool `json:"keep_wallet,omitempty"` } // GMInventoryClearResult 描述清空结果。 type GMInventoryClearResult struct { CharacterID string `json:"character_id"` Collection string `json:"collection"` Removed int `json:"removed"` KeptSlots []string `json:"kept_slots,omitempty"` } // GMClearInventoryForTool 清空背包(或穿戴装备)。 // // 为什么不复用同包已有的 GMClearInventory:那个版本会把 Slots 整个替换成空 map, // 连同主背包 0:0~0:2 的固定钱包槽(金币/复活币/点券载体)一起删掉,客户端随后 // 读不到货币。工具端的"清空背包"必须保留这些受保护槽位。 func (s *Service) GMClearInventoryForTool(ctx context.Context, request GMInventoryClearRequest) (GMInventoryClearResult, error) { result := GMInventoryClearResult{} if s == nil { return result, ErrGMRepositoryUnavailable } if ctx == nil { ctx = context.Background() } characterID := strings.TrimSpace(request.CharacterID) if characterID == "" { return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid) } result.CharacterID = characterID collection := strings.ToLower(strings.TrimSpace(request.Collection)) if collection == "" { collection = "slots" } result.Collection = collection worn := collection == "wear" || collection == "equipment" keepWallet := true if request.KeepWallet != nil { keepWallet = *request.KeepWallet } repositories, available := s.repositoryGroup() if !available || repositories.CharacterSettlement == nil { return result, ErrGMRepositoryUnavailable } err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error { now := time.Now().UTC() if worn { if tx.Equipment == nil { return ErrGMRepositoryUnavailable } record, found, loadErr := tx.Equipment.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return nil } record = dnfrepo.CloneEquipment(record) result.Removed = len(record.Entries) record.Entries = map[string]dnfrepo.EquipmentEntry{} record.UpdatedAt = now return tx.Equipment.Save(ctx, record) } if tx.Inventory == nil { return ErrGMRepositoryUnavailable } record, found, loadErr := tx.Inventory.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID) } record = dnfrepo.CloneInventory(record) kept := make([]string, 0, 3) for key := range record.Slots { listType, slot, ok := dnfrepo.ParseInventorySlotKey(key) if !ok { continue } if keepWallet && listType == dnfrepo.MainInventoryListType && slot >= 0 && slot <= 2 { kept = append(kept, key) } } sortStrings(kept) result.KeptSlots = kept for key := range record.Slots { listType, slot, ok := dnfrepo.ParseInventorySlotKey(key) if ok && keepWallet && listType == dnfrepo.MainInventoryListType && slot >= 0 && slot <= 2 { continue } delete(record.Slots, key) result.Removed++ } record.UpdatedAt = now return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, record, dnfrepo.InventoryFieldSlots) }) if err != nil { return result, err } return result, nil } // sortStrings 是本文件内部用的小工具,避免为了排序引入额外依赖。 func sortStrings(values []string) { for i := 1; i < len(values); i++ { for j := i; j > 0 && values[j] < values[j-1]; j-- { values[j], values[j-1] = values[j-1], values[j] } } } // gmCloneStack 深拷贝一条道具行(ItemStack 含 map/切片,直接赋值会共享底层数据)。 func gmCloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack { clone := stack if stack.RawEntry != nil { clone.RawEntry = append([]byte(nil), stack.RawEntry...) } if stack.Extra != nil { clone.Extra = make(map[string]string, len(stack.Extra)) for key, value := range stack.Extra { clone.Extra[key] = value } } return clone }