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 需要的"道具发放 / 删除"admin 能力: // // POST /local/dnf-gm/item/grant 按 item_id 发道具进背包(服务端决定格位) // POST /local/dnf-gm/item/delete 按 (list_type, slot) 删除一整行 // // 为什么复用副本拾取的那套逻辑 // 服务端已经有一份经过线上验证的"把道具放进背包"实现: // dungeon.currentDungeonPickupPlacement + PlacePickupItem。它负责 // - 按道具种类(stackable / equipment)推导容器与槽位区间(含公会勋章 list 38) // - 已有的可堆叠行先并堆,否则找最小空格开新行 // - 装备行构造设备品质/耐久/封印象 etc. // 所以 GM 发放直接走同一条路,不另外发明一套格位规则。 var ( // ErrGMItemGrantUnavailable 表示仓储或道具目录不可用。 ErrGMItemGrantUnavailable = errors.New("gm item grant unavailable") // ErrGMItemNotFound 表示 item_id 在当前 PVF 里解析不到。 ErrGMItemNotFound = errors.New("gm item not found in pvf") // ErrGMItemRowNotFound 表示要删除的背包行不存在。 ErrGMItemRowNotFound = errors.New("gm item row not found") // ErrGMInventoryNotFound 表示该角色还没有背包记录。 ErrGMInventoryNotFound = errors.New("gm character inventory not found") ) // GMItemGrantRequest 对应 POST /local/dnf-gm/item/grant。 type GMItemGrantRequest struct { CharacterID string `json:"character_id"` ItemID uint32 `json:"item_id"` Count uint32 `json:"count,omitempty"` } // GMItemGrantResult 描述一次发放的落点。 type GMItemGrantResult 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"` Merged bool `json:"merged"` ItemName string `json:"item_name,omitempty"` PVFPath string `json:"pvf_path,omitempty"` } const gmItemDefaultCount uint32 = 1 // GMGrantItem 把 count 个 item_id 放进角色背包,返回实际落点。 // // 容器与槽位区间完全由服务端的 PVF 定义推导(见 currentDungeonPickupPlacement), // 因此公会勋章会落到 list 38、装备会落到装备段,玩家端不会再出现"捡到不对、 // 重登才正常"。 func (s *Service) GMGrantItem(ctx context.Context, request GMItemGrantRequest) (GMItemGrantResult, error) { result := GMItemGrantResult{} 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 result.Count = count 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 // 可堆叠道具不能超过 PVF 的堆叠上限。 if definition.StackLimit > 0 && int64(count) > definition.StackLimit { count = uint32(definition.StackLimit) result.Count = count } placement := currentDungeonPickupPlacement(definition, count) 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) } // 发放前先记录该容器里是否已有同 id 行,用来区分"并堆"与"开新行"。 merged := false grant, err := owner.GrantPickupItem(ctx, dnfdungeon.PickupItemCommand{ CharacterID: characterID, Placement: placement, Finalize: func(slot int16, stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) { if stack.Count > int64(count) { merged = true stack.Count = int64(count) } if stack.Extra == nil { stack.Extra = map[string]string{} } stack.Extra["source"] = "gm_tool_grant" stack.Extra["granted_at"] = time.Now().UTC().Format(time.RFC3339) return stack, nil }, 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.Merged = merged 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", []alignedcmd.ItemSlotRefresh{{ListType: placement.ListType, SlotIndex: int16(grant.Slot)}}, ); pushErr != nil { s.logWarn("dnf90 GM item grant live refresh failed", "character_id", characterID, "entry_key", result.EntryKey, "error", pushErr) } } } return result, nil } // GMItemDeleteRequest 对应 POST /local/dnf-gm/item/delete。 type GMItemDeleteRequest struct { CharacterID string `json:"character_id"` ListType *int `json:"list_type,omitempty"` Slot *int `json:"slot,omitempty"` EntryKey string `json:"entry_key,omitempty"` } // GMItemDeleteResult 描述删除结果。 type GMItemDeleteResult struct { CharacterID string `json:"character_id"` EntryKey string `json:"entry_key"` ItemID int64 `json:"item_id"` Removed int64 `json:"removed"` } // GMDeleteItem 删除背包里的一整行。 // // entry_key 是 ":"(与客户端 ItemCore 的槽位编号一致), // 也接受拆开的 list_type + slot。 func (s *Service) GMDeleteItem(ctx context.Context, request GMItemDeleteRequest) (GMItemDeleteResult, error) { result := GMItemDeleteResult{} 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 listType, slot, err := gmResolveItemRow(request) if err != nil { return result, err } entryKey := fmt.Sprintf("%d:%d", listType, slot) result.EntryKey = entryKey repositories, available := s.repositoryGroup() if !available || repositories.Inventory == nil || repositories.CharacterSettlement == nil { return result, ErrGMRepositoryUnavailable } err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error { inventory, found, loadErr := tx.Inventory.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return ErrGMItemRowNotFound } inventory = dnfrepo.CloneInventory(inventory) stack, present := inventory.Slots[entryKey] if !present { return fmt.Errorf("%w: entry_key=%s", ErrGMItemRowNotFound, entryKey) } result.ItemID = stack.ItemID result.Removed = stack.Count delete(inventory.Slots, entryKey) inventory.UpdatedAt = time.Now().UTC() return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots) }) if err != nil { return result, err } return result, nil } func gmResolveItemRow(request GMItemDeleteRequest) (byte, int, error) { entryKey := strings.TrimSpace(request.EntryKey) if entryKey != "" { parts := strings.SplitN(entryKey, ":", 2) if len(parts) != 2 { return 0, 0, fmt.Errorf("%w: entry_key must be :", ErrGMRequestInvalid) } listType, err := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8) if err != nil { return 0, 0, fmt.Errorf("%w: list_type in entry_key is invalid", ErrGMRequestInvalid) } slot, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 32) if err != nil || slot < 0 { return 0, 0, fmt.Errorf("%w: slot in entry_key is invalid", ErrGMRequestInvalid) } return byte(listType), int(slot), nil } if request.ListType == nil || request.Slot == nil { return 0, 0, fmt.Errorf("%w: list_type and slot are required", ErrGMRequestInvalid) } if *request.ListType < 0 || *request.ListType > 255 || *request.Slot < 0 { return 0, 0, fmt.Errorf("%w: list_type or slot out of range", ErrGMRequestInvalid) } return byte(*request.ListType), *request.Slot, nil }