DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,706 @@
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
}