package dnfbridge import ( "context" "fmt" "strings" "time" dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon" dnfrepo "longheng.io/server/internal/modules/dnf/repository" dnfsocketemblem "longheng.io/server/internal/modules/dnf/socketemblem" ) // 本文件实现 GM 发放时的 **附魔宝珠** 与 **时装/装备徽章**。 // // 为什么不能只写 Extra // 徽章不是普通的 Extra 键值:它走 socketemblem 的 // data[0]=已开孔数 + data[1..]=各孔徽章 ID 的向量,而且镶嵌时 // currentApplyEquipmentEmblems 会**从背包消耗**对应徽章行。 // 所以发放带徽章的装备必须复用那套投影逻辑,不能自己拼 key。 // // 附魔宝珠相对简单:权威写法是 inventory.setEnchantFields —— // Extra["value_a"] / ["enchant_card_id"] = 宝珠 item_id // Extra["byte_12"] / ["enchant_upgrade_count"] = 升级次数 // 其中 value_a 会被 currentItemListEntryFromStack 写进 raw[0x0E:0x12]。 const ( gmEmblemListMain byte = currentSocketListMain gmEmblemListAvatar byte = currentSocketListAvatar gmEmblemMaxSockets = 4 ) // GMEmblemRequest 是请求里的一枚徽章。 // // SocketIndex 省略(<0)时按出现顺序自动分配 0,1,2,…; // EmblemSlot 省略(<0)时按 EmblemItemID 在主背包里找第一行并消耗 1 个。 type GMEmblemRequest struct { SocketIndex *int `json:"socket_index,omitempty"` EmblemItemID uint32 `json:"emblem_item_id"` EmblemSlot *int `json:"emblem_slot,omitempty"` } // GMItemWithExtrasRequest 对应 POST /local/dnf-gm/item/grant-with-emblems。 type GMItemWithExtrasRequest struct { CharacterID string `json:"character_id"` ItemID uint32 `json:"item_id"` // 附魔宝珠:直接落到目标装备上(不消耗背包里的宝珠行)。 EnchantBeadItemID uint32 `json:"enchant_bead_item_id,omitempty"` EnchantUpgrade *int `json:"enchant_upgrade_count,omitempty"` // 徽章 Emblems []GMEmblemRequest `json:"emblems,omitempty"` // 已开孔数;0 表示按徽章数量自动开孔(上限由装备部位决定)。 OpenSockets *int `json:"open_sockets,omitempty"` } // GMItemWithExtrasResult 描述带附魔/徽章发放的结果。 type GMItemWithExtrasResult struct { CharacterID string `json:"character_id"` ItemID uint32 `json:"item_id"` ListType byte `json:"list_type"` Slot int `json:"slot"` EntryKey string `json:"entry_key"` ItemName string `json:"item_name,omitempty"` IsAvatar bool `json:"is_avatar"` EnchantCardID int64 `json:"enchant_card_id,omitempty"` OpenSockets int `json:"open_sockets"` Sockets []int64 `json:"sockets,omitempty"` ConsumedEmblems []string `json:"consumed_emblems,omitempty"` Extra map[string]string `json:"extra,omitempty"` } // GMGrantItemWithEmblems 发放一件带附魔宝珠与徽章的装备 / 时装。 // // 流程(同一事务内): // 1. 构造道具行,写入附魔字段(Extra + 由 RawEntry 序列化承载 0x0E 宝珠 ID) // 2. 放入背包(沿用副本拾取那套容器/槽位规则) // 3. 若给了徽章:先把徽章行放进背包(以便被消耗),再按 socketemblem 的投影 // 逻辑开孔并镶嵌,最后写回目标行的 socket 向量 func (s *Service) GMGrantItemWithEmblems( ctx context.Context, request GMItemWithExtrasRequest, ) (GMItemWithExtrasResult, error) { result := GMItemWithExtrasResult{} 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) } result.CharacterID = characterID result.ItemID = request.ItemID 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 // 目标必须是装备/时装:附魔与徽章只对这两类有意义。 rule, ruleOK := currentEquipmentPlacementRuleForPVFType(definition.EquipmentType) isAvatar := ruleOK && rule.class == currentEquipmentPlacementClassAvatar if definition.Kind != dungeonDropItemEquipment { return result, fmt.Errorf("%w: item=%d is not equipment; enchant/emblems require equipment", ErrGMRequestInvalid, request.ItemID) } result.IsAvatar = isAvatar if request.OpenSockets != nil && (*request.OpenSockets < 0 || *request.OpenSockets > gmEmblemMaxSockets) { return result, fmt.Errorf("%w: open_sockets must be 0..%d", ErrGMRequestInvalid, gmEmblemMaxSockets) } // 附魔宝珠校验:必须是已解析的道具。 var enchantCardID int64 if request.EnchantBeadItemID != 0 { if _, beadErr := catalog.ResolveItem(request.EnchantBeadItemID); beadErr != nil { return result, fmt.Errorf("%w: enchant bead item=%d resolved=false", ErrGMItemNotFound, request.EnchantBeadItemID) } enchantCardID = int64(request.EnchantBeadItemID) } upgradeCount := 0 if request.EnchantUpgrade != nil { if *request.EnchantUpgrade < 0 || *request.EnchantUpgrade > 255 { return result, fmt.Errorf("%w: enchant_upgrade_count must be 0..255", ErrGMRequestInvalid) } upgradeCount = *request.EnchantUpgrade } stack, buildErr := s.buildGMGrantItemStack(ctx, catalog, definition, 1, gmGrantItemOptions{}) if buildErr != nil { return result, buildErr } if enchantCardID != 0 { // 与 inventory.setEnchantFields 写同样的键集合。 stack.Extra["value_a"] = fmt.Sprintf("%d", enchantCardID) stack.Extra["enchant_card_id"] = fmt.Sprintf("%d", enchantCardID) stack.Extra["byte_12"] = fmt.Sprintf("%d", upgradeCount) stack.Extra["enchant_upgrade_count"] = fmt.Sprintf("%d", upgradeCount) result.EnchantCardID = enchantCardID } // 徽章参数先规范化,任何非法输入都在动库之前拒绝。 emblems, autoSocketCount, normErr := gmNormalizeEmblems(request.Emblems) if normErr != nil { return result, normErr } openSockets := autoSocketCount if request.OpenSockets != nil && *request.OpenSockets > openSockets { openSockets = *request.OpenSockets } placement := currentDungeonPickupPlacement(definition, 1) placement.BuildNew = func(int16) (dnfrepo.ItemStack, error) { return gmCloneStack(stack), nil } placement.NormalizeExisting = nil repositories, available := s.repositoryGroup() if !available || repositories.CharacterItems == nil { 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.OpenSockets = openSockets // 没有徽章时也要把附魔序列化进原始行 —— **必须放在发放之后**。 // 构建阶段的 raw 会被落库过程冲掉(实测:只在 BuildNew 里刷新时 // raw[0x0E:0x12] 仍是 0),而徽章路径之所以正确,正是因为它是在目标行 // 已经落位之后才调用 currentRefreshStackRawEntry。 if len(emblems) == 0 { if err := s.gmRefreshGrantedItemRaw(ctx, repositories, grant.Slot, placement.ListType, characterID); err != nil { return result, err } if inventory, found, loadErr := repositories.Inventory.Load(ctx, characterID); loadErr == nil && found { if final, present := inventory.Slots[result.EntryKey]; present { result.Extra = final.Extra } } return result, nil } return s.gmAttachEmblemsToGrantedItem(ctx, repositories, catalog, rule, request.ItemID, placement.ListType, int16(grant.Slot), openSockets, emblems, result) } // gmRefreshGrantedItemRaw 在目标行已经落位之后,用 Extra 重新序列化它的原始行。 // // 顺序很关键:必须"先落库、后刷新",否则刷新出来的 raw 会被落库路径覆盖回旧值。 func (s *Service) gmRefreshGrantedItemRaw( ctx context.Context, repositories dnfrepo.Group, slot uint16, listType byte, characterID string, ) error { if repositories.CharacterItems == nil { return ErrGMRepositoryUnavailable } key := fmt.Sprintf("%d:%d", listType, slot) return repositories.CharacterItems.WithinCharacterItems(ctx, characterID, func( inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository, ) error { record, found, loadErr := inventories.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID) } stack, present := record.Slots[key] if !present { return fmt.Errorf("%w: entry_key=%s", ErrGMItemRowNotFound, key) } record = dnfrepo.CloneInventory(record) currentRefreshStackRawEntry(&stack, listType, int16(slot)) record.Slots[key] = stack record.UpdatedAt = time.Now().UTC() return dnfrepo.SaveInventoryFields(ctx, inventories, record, dnfrepo.InventoryFieldSlots) }) } // gmNormalizeEmblems 校验并补全徽章参数,返回"至少需要开几孔"。 func gmNormalizeEmblems(values []GMEmblemRequest) ([]currentEmblemApplyRequest, int, error) { if len(values) == 0 { return nil, 0, nil } if len(values) > gmEmblemMaxSockets { return nil, 0, fmt.Errorf("%w: at most %d emblems", ErrGMRequestInvalid, gmEmblemMaxSockets) } out := make([]currentEmblemApplyRequest, 0, len(values)) seenSocket := map[int]bool{} nextIndex := 0 for _, value := range values { if value.EmblemItemID == 0 { return nil, 0, fmt.Errorf("%w: emblem_item_id is required", ErrGMRequestInvalid) } index := nextIndex if value.SocketIndex != nil { index = *value.SocketIndex } if index < 0 || index >= gmEmblemMaxSockets { return nil, 0, fmt.Errorf("%w: socket_index must be 0..%d", ErrGMRequestInvalid, gmEmblemMaxSockets-1) } if seenSocket[index] { return nil, 0, fmt.Errorf("%w: socket_index %d repeated", ErrGMRequestInvalid, index) } seenSocket[index] = true if index >= nextIndex { nextIndex = index + 1 } slot := int16(-1) if value.EmblemSlot != nil { if *value.EmblemSlot < 0 { return nil, 0, fmt.Errorf("%w: emblem_slot must not be negative", ErrGMRequestInvalid) } slot = int16(*value.EmblemSlot) } out = append(out, currentEmblemApplyRequest{ EmblemSlot: slot, EmblemItemID: int64(value.EmblemItemID), SocketIndex: byte(index), }) } return out, nextIndex, nil } // gmAttachEmblemsToGrantedItem 在同一事务里给刚放好的装备开孔并镶嵌徽章。 // // 徽章行会被临时放进同一个背包记录里,交给 currentApplyEquipmentEmblems 消耗 —— // 那条路径是游戏内镶嵌用的同一份实现,因此不会写出客户端读不了的向量。 func (s *Service) gmAttachEmblemsToGrantedItem( ctx context.Context, repositories dnfrepo.Group, catalog *pvfDungeonDropCatalog, rule currentEquipmentPlacementRule, targetItemID uint32, listType byte, slot int16, openSockets int, emblems []currentEmblemApplyRequest, result GMItemWithExtrasResult, ) (GMItemWithExtrasResult, error) { owner, err := dnfsocketemblem.NewOwner(repositories) if err != nil { return result, fmt.Errorf("%w: socket emblem owner: %v", ErrGMItemGrantUnavailable, err) } targetKey := fmt.Sprintf("%d:%d", listType, slot) attach := func(assets *dnfsocketemblem.Assets) (dnfsocketemblem.Changes, error) { inventory := assets.Inventory if inventory == nil { return dnfsocketemblem.Changes{}, errCurrentSocketInventoryMissing } target, ok := inventory.Slots[targetKey] if !ok || target.ItemID != int64(targetItemID) { return dnfsocketemblem.Changes{}, fmt.Errorf("%w: target %s", errCurrentSocketTargetMissing, targetKey) } // 1) 把徽章行临时放进背包(原地消耗,不再清理; // 镶嵌成功后它们就"消耗掉了",与游戏内一致)。 resolved, materialiseErr := gmMaterialiseEmblems(inventory, emblems) if materialiseErr != nil { return dnfsocketemblem.Changes{}, materialiseErr } // 2) 打开所需孔位。 isAvatar := rule.class == currentEquipmentPlacementClassAvatar if isAvatar { data := currentAvatarSocketData(target.Extra) want := openSockets if want <= 0 { want = len(resolved) } for i := 0; i < want && i < currentAvatarSocketCount; i++ { if data[i] == 0 && i >= int(data[0]) { // 空孔记 0xFF 占位,交给应用阶段写入真实徽章 ID。 data[i] = 0xFF } } data[0] = byte(want) if applyErr := currentApplyAvatarEmblems(catalog, inventory, &data, resolved); applyErr != nil { return dnfsocketemblem.Changes{}, applyErr } currentApplyAvatarSocketDataToStack(&target, listType, slot, data) } else { dataBytes := currentEquipmentEmblemData(target.Extra, target.RawEntry) maxOpen := currentEquipmentSocketOpenCount(rule) want := openSockets if want <= 0 { want = len(resolved) } if want > maxOpen { want = maxOpen } currentEnsureEquipmentEmblemSocketsOpen(&dataBytes, maxOpen, want) if applyErr := currentApplyEquipmentEmblems(catalog, rule, inventory, &dataBytes, resolved); applyErr != nil { return dnfsocketemblem.Changes{}, applyErr } currentApplyEquipmentEmblemDataToStack(&target, listType, slot, dataBytes, currentEquipmentJewelSocketType(rule)) } inventory.Slots[targetKey] = target return dnfsocketemblem.Changes{Inventory: true}, nil } if err := owner.AttachEquipmentEmblems(ctx, dnfsocketemblem.Command{ CharacterID: result.CharacterID, Project: attach, }); err != nil { return result, err } // 回读最终行,把实际写入的附魔/徽章信息返回给工具端。 inventory, found, loadErr := repositories.Inventory.Load(ctx, result.CharacterID) if loadErr == nil && found { if final, present := inventory.Slots[targetKey]; present { result.Extra = final.Extra if text := strings.TrimSpace(final.Extra["enchant_card_id"]); text != "" { var parsed int64 if _, scanErr := fmt.Sscanf(text, "%d", &parsed); scanErr == nil { result.EnchantCardID = parsed } } if isAvatarRule(rule) { data := currentAvatarSocketData(final.Extra) result.OpenSockets = int(data[0]) for i := 1; i < len(data) && i <= result.OpenSockets; i++ { if data[i] != 0 && data[i] != 0xFF { result.Sockets = append(result.Sockets, int64(data[i])) } } } else { data := currentEquipmentEmblemData(final.Extra, final.RawEntry) result.OpenSockets = int(data[0]) for i := 1; i < len(data) && i <= result.OpenSockets; i++ { if data[i] != 0 && data[i] != 0xFF { result.Sockets = append(result.Sockets, int64(data[i])) } } } } } return result, nil } func isAvatarRule(rule currentEquipmentPlacementRule) bool { return rule.class == currentEquipmentPlacementClassAvatar } // gmMaterialiseEmblems 把请求里的徽章落到背包行,返回带真实槽位的申请列表。 // // 已经给了 EmblemSlot 就直接用;否则找同 item_id 的第一行消耗 1 个。 // 找不到时**临时插入**一行(工具端通常还没把徽章发给角色)。 func gmMaterialiseEmblems( inventory *dnfrepo.InventoryRecord, emblems []currentEmblemApplyRequest, ) ([]currentEmblemApplyRequest, error) { if inventory.Slots == nil { inventory.Slots = map[string]dnfrepo.ItemStack{} } out := make([]currentEmblemApplyRequest, 0, len(emblems)) // 临时槽从主背包末段往前找,避免占用正常装备/消耗品段。 nextTempSlot := int16(350) for _, emblem := range emblems { slot := emblem.EmblemSlot if slot >= 0 { if existing, ok := inventory.Slots[fmt.Sprintf("%d:%d", gmEmblemListMain, slot)]; ok && existing.ItemID == emblem.EmblemItemID { emblem.EmblemSlot = slot out = append(out, emblem) continue } } // 找同 id 的现有行。 foundSlot := int16(-1) for key, candidate := range inventory.Slots { var keyList byte var keySlot int16 if _, scanErr := fmt.Sscanf(key, "%d:%d", &keyList, &keySlot); scanErr != nil { continue } if keyList != gmEmblemListMain || candidate.ItemID != emblem.EmblemItemID || candidate.Count <= 0 { continue } if foundSlot < 0 || keySlot < foundSlot { foundSlot = keySlot } } if foundSlot < 0 { // 临时插入一行,让镶嵌阶段能"消耗"掉它。 for { key := fmt.Sprintf("%d:%d", gmEmblemListMain, nextTempSlot) if _, taken := inventory.Slots[key]; !taken { inventory.Slots[key] = dnfrepo.ItemStack{ ItemID: emblem.EmblemItemID, Count: 1, Extra: map[string]string{ "source": "gm_tool_emblem_materialise", "item_kind": "stackable", }, } foundSlot = nextTempSlot nextTempSlot-- break } nextTempSlot-- if nextTempSlot < 300 { return nil, fmt.Errorf("%w: no temporary slot for emblem %d", ErrGMRequestInvalid, emblem.EmblemItemID) } } } emblem.EmblemSlot = foundSlot out = append(out, emblem) } return out, nil }