服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
707 lines
25 KiB
Go
707 lines
25 KiB
Go
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
|
||
}
|