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