Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

707 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}