Files
90CNServer/server/internal/services/dnfbridge/dungeon_discard.go
T
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

600 lines
22 KiB
Go
Raw 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"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentDungeonDiscardOpcode uint16 = uint16(dnfenum.CmdPacketDropItem)
currentDungeonDiscardSceneOpcode uint16 = 40
currentDungeonDiscardSceneBodySize = 2 + 2 + 2 + 4 + currentItemListEntryWireSize + 1 + 2
currentDungeonDiscardRequestSize = 2 + 2 + 1 + 2 + 4 + 1
currentDungeonDiscardAckSize = 1 + 2 + 4
currentDungeonDiscardSceneMode = byte(0)
currentDungeonDiscardUnscopedContext = uint16(0)
)
var (
errCurrentDungeonDiscardMalformed = errors.New("current dungeon discard request is malformed")
errCurrentDungeonDiscardRuntimeMissing = errors.New("current dungeon discard runtime is missing")
errCurrentDungeonDiscardTransaction = errors.New("current dungeon discard transaction is unavailable")
errCurrentDungeonDiscardInventoryMissing = errors.New("current dungeon discard inventory is missing")
errCurrentDungeonDiscardItemMismatch = errors.New("current dungeon discard item does not match the authoritative inventory")
errCurrentDungeonDiscardCountInvalid = errors.New("current dungeon discard count is invalid")
errCurrentDungeonDiscardNotTradeable = errors.New("current dungeon discard item is not tradeable")
errCurrentDungeonDiscardItemLocked = errors.New("current dungeon discard item is locked")
errCurrentDungeonDiscardItemExpired = errors.New("current dungeon discard item is expired")
errCurrentDungeonDiscardRestoreConflict = errors.New("current dungeon discarded item cannot be restored to its source slot")
)
type currentDungeonDiscardRequest struct {
PositionX uint16
PositionY uint16
ListType byte
SourceSlot int16
Count uint32
SceneMode byte
AckPayload []byte
}
type runtimeDungeonDiscardOrigin struct {
ListType byte
SourceSlot int16
AccountOwned bool
Stack dnfrepo.ItemStack
}
// parseCurrentDungeonDiscardRequest 解析当前客户端的 op47 丢弃请求。
//
// body[11] 曾经被当成「必须为 0 的 mode」硬校验(常量
// currentDungeonDiscardRequestMode)。线上取证证明这一位不是常量:负重上限被
// 顶破时客户端仍允许拾取,随后立刻把刚越线的那一件装备丢回地面,这一版 op47
// 的 body[11] 就是 1(packet_log 里三次 discard-blocked 全是
// `list=0 slot=… count=1 mode=1`,而唯一一次 `mode=0` 的成功丢弃发生在未超重
// 状态)。硬校验等于把整包判 malformed,而 malformed 分支既不落库也不回包,
// 客户端于是表现为「超重后丢不掉、且没有任何提示」。
//
// 这一位在服务端没有消费者:request.SceneMode 解析出来之后没有下游读者,op40
// 场景包的模式字节由 buildCurrentDungeonDiscardSceneBody 的调用方传常量
// currentDungeonDiscardSceneMode(0)(见 handleCurrentDungeonDiscard)。所以放行
// 这一位不改变任何出站字节,也不新增代码路径——被放行的请求仍要过完全相同的
// 权威校验(容器/格位存在、PVF 可交易、未锁定、未过期、数量匹配)。
// 这里只把它原样记进 SceneMode 供日志取证,不参与任何判定。
func parseCurrentDungeonDiscardRequest(body []byte) (currentDungeonDiscardRequest, error) {
if len(body) != currentDungeonDiscardRequestSize {
return currentDungeonDiscardRequest{}, fmt.Errorf("%w: body_len=%d", errCurrentDungeonDiscardMalformed, len(body))
}
listType := body[4]
sourceSlot := binary.LittleEndian.Uint16(body[5:7])
count := binary.LittleEndian.Uint32(body[7:11])
sceneMode := body[11]
if listType != dnfrepo.MainInventoryListType ||
sourceSlot > math.MaxInt16 ||
count == 0 {
return currentDungeonDiscardRequest{}, fmt.Errorf(
"%w: list=%d slot=%d count=%d mode=%d",
errCurrentDungeonDiscardMalformed,
listType,
sourceSlot,
count,
sceneMode,
)
}
ackPayload := make([]byte, currentDungeonDiscardAckSize)
ackPayload[0] = listType
binary.LittleEndian.PutUint16(ackPayload[1:3], sourceSlot)
binary.LittleEndian.PutUint32(ackPayload[3:7], count)
return currentDungeonDiscardRequest{
PositionX: binary.LittleEndian.Uint16(body[0:2]),
PositionY: binary.LittleEndian.Uint16(body[2:4]),
ListType: listType,
SourceSlot: int16(sourceSlot),
Count: count,
SceneMode: sceneMode,
AckPayload: ackPayload,
}, nil
}
func buildCurrentDungeonDiscardSceneBody(
actorObjectKey uint16,
positionX uint16,
positionY uint16,
objectKey uint32,
sceneSlot uint16,
stack dnfrepo.ItemStack,
definition dungeonDropItemDefinition,
amount uint32,
sceneMode byte,
) ([]byte, currentItemListEntry, error) {
if actorObjectKey == 0 || objectKey == 0 || sceneSlot == 0 ||
stack.ItemID <= 0 || uint64(stack.ItemID) > math.MaxUint32 ||
uint32(stack.ItemID) != definition.ItemID || amount == 0 ||
sceneMode != currentDungeonDiscardSceneMode {
return nil, currentItemListEntry{}, errCurrentDungeonDiscardMalformed
}
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, int16(sceneSlot), stack)
instanceValue := amount
if definition.Kind == dungeonDropItemEquipment {
if amount != 1 {
return nil, currentItemListEntry{}, errCurrentDungeonDiscardCountInvalid
}
instanceValue = binary.LittleEndian.Uint32(entry.data[0x06:0x0A])
if instanceValue == 0 {
return nil, currentItemListEntry{}, errDungeonDeathDropItemInvalid
}
}
entry.patchCore(int16(sceneSlot), definition.ItemID, instanceValue)
var writer packetWriter
writer.writeUint16(actorObjectKey)
writer.writeUint16(positionX)
writer.writeUint16(positionY)
writer.writeUint32(objectKey)
writer.writeBytes(entry.data[:])
writer.writeByte(sceneMode)
// Current EXE sub_1D74B60 treats a nonzero trailing u16 as a protected
// runtime-context guard and returns before creating the ground object when
// it does not match. Public dungeon discards are deliberately unscoped.
writer.writeUint16(currentDungeonDiscardUnscopedContext)
body := writer.bytes()
if len(body) != currentDungeonDiscardSceneBodySize {
return nil, currentItemListEntry{}, fmt.Errorf(
"%w: scene_body_len=%d",
errCurrentDungeonDiscardMalformed,
len(body),
)
}
return body, entry, nil
}
// currentDungeonDiscardTradeable 是「这件物品能不能交易」的唯一判定,也是上架
// (拍卖行 op183 / 摆摊 op87)和副本内丢弃共用的那一个。它只看 PVF 的
// [attach type]:只有 `[free]` 与 `[sealing]` 放行,其余取值
// (`[trade]`、`[account]`、`[trade delete]`、空值…)一律拒绝。
// 品级 [rarity] 完全不参与判断。
//
// `[sealing]` 还有一层封装态要求,见 currentDungeonDiscardSealRequired 与
// currentDungeonDiscardSealed;本函数只回答「这个 attach type 允不允许交易」。
func currentDungeonDiscardTradeable(definition dungeonDropItemDefinition) bool {
switch normalizeMagicBoxPVFType(definition.AttachType) {
case "free", "sealing":
return true
default:
return false
}
}
// currentDungeonDiscardSealRequired 报告该定义是否必须处于封装态才可交易。
// 只有 `[sealing]` 需要封装;`[free]` 不看封装状态。
func currentDungeonDiscardSealRequired(definition dungeonDropItemDefinition) bool {
return normalizeMagicBoxPVFType(definition.AttachType) == "sealing"
}
// currentDungeonDiscardSealed 是封装状态的权威读法,与 currentItemListBindFlag /
// itemgrade.SealFlag 同源:raw_entry[0x0D] → extra seal_flag/seal → Bind。
//
// 不能用 stack.Bind 代替:Bind 是 DB 的 bind_flag(装备绑定),封装标记是物品行
// 的 +0x0D 那一位。GM 邮件发放的 `[sealing]` 装备恰好是「已封装但 bind_flag=0」
// (gmApplyMailEquipmentSeal 只写 extra seal_flag/seal,不像 itemgrade 的
// applyResealState 那样同时写 Bind),用 Bind 判断会把封装件误判成解封件,
// 于是「封装好的装备在拍卖行和摆摊都上不了架」。
func currentDungeonDiscardSealed(stack dnfrepo.ItemStack) bool {
return currentItemSealFlag(stack) != 0
}
func currentDungeonDiscardStackLocked(stack dnfrepo.ItemStack) bool {
if stack.Extra == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(stack.Extra["equipment_lock_state"])) {
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
return true
default:
return false
}
}
func currentDungeonDiscardStackExpired(stack dnfrepo.ItemStack, now time.Time) bool {
if now.IsZero() {
now = time.Now().UTC()
}
if !stack.ExpireAt.IsZero() && !stack.ExpireAt.After(now) {
return true
}
expire := currentItemListStackExpire(stack)
return expire != 0 && int64(expire) <= now.Unix()
}
func cloneRuntimeDungeonDiscardStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if stack.Extra != nil {
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
}
return stack
}
func (s *Service) handleCurrentDungeonDiscard(session *gameSession, body []byte) (bool, error) {
if session == nil {
return false, nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil {
return false, nil
}
if runtime.Session == nil || runtime.Room == nil || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"body_len", len(body),
"reason", "active_dungeon_runtime_owner_invalid",
"error", errCurrentDungeonDiscardRuntimeMissing)
return true, nil
}
request, err := parseCurrentDungeonDiscardRequest(body)
if err != nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"body_len", len(body),
"reason", "current_exe_op47_request_malformed",
"error", err)
return true, nil
}
scene, ok := runtime.Session.Scene()
if !ok {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "current_dungeon_scene_missing",
"error", errCurrentDungeonDiscardRuntimeMissing)
return true, nil
}
monsterCatalog, err := s.dungeonMonsterCatalog()
if err != nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "runtime_pvf_catalog_unavailable",
"error", err)
return true, nil
}
dropCatalog, err := monsterCatalog.DropCatalog()
if err != nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "runtime_pvf_drop_catalog_unavailable",
"error", err)
return true, nil
}
owner := runtime.DropOwner
if owner == nil {
owner = newRuntimeDungeonDropOwner()
}
objectKey := owner.nextObjectKeyAtOrAbove(runtime.NextObjectKey)
if objectKey == 0 || objectKey > math.MaxUint16 {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "dungeon_drop_object_key_exhausted",
"error", errDungeonDropObjectKeyRange)
return true, nil
}
if owner.nextSceneSlot == 0 || owner.nextSceneSlot == math.MaxUint16 {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "dungeon_drop_scene_slot_exhausted",
"error", errDungeonDropSceneSlotRange)
return true, nil
}
sceneSlot := owner.nextSceneSlot
repositories, ok := s.repositoryGroup()
if !ok || repositories.AccountAssets == nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "account_character_assets_transaction_missing",
"error", errCurrentDungeonDiscardTransaction)
return true, nil
}
assetOwner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"source_slot", request.SourceSlot,
"reason", "dungeon_asset_owner_missing",
"error", errCurrentDungeonDiscardTransaction)
return true, nil
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
characterID := strconv.Itoa(int(session.selectedCharacterID))
now := time.Now().UTC()
accountOwned := dnfrepo.IsAccountSharedInventorySlot(request.ListType, request.SourceSlot)
var (
itemID uint32
definition dungeonDropItemDefinition
removedStack dnfrepo.ItemStack
remaining int64
sceneBody []byte
sceneEntry currentItemListEntry
itemUpdate currentItemListEntry
)
key := currentDungeonPickupMainSlotKey(request.SourceSlot)
if accountOwned {
key = dnfrepo.AccountSharedInventorySlotKey(request.SourceSlot)
}
err = assetOwner.MutateOwnedInventory(context.Background(), dnfdungeon.OwnedInventoryMutationCommand{
AccountID: accountID,
CharacterID: characterID,
AccountOwned: accountOwned,
UpdatedAt: now,
Apply: func(slots map[string]dnfrepo.ItemStack) (bool, error) {
stack, foundStack := slots[key]
if !foundStack || stack.ItemID <= 0 || uint64(stack.ItemID) > math.MaxUint32 || stack.Count <= 0 {
return false, errCurrentDungeonDiscardItemMismatch
}
itemID = uint32(stack.ItemID)
definition, err = dropCatalog.ResolveItem(itemID)
if err != nil || !currentDungeonDiscardTradeable(definition) {
return false, errors.Join(err, errCurrentDungeonDiscardNotTradeable)
}
if stack.Bind {
return false, errCurrentDungeonDiscardNotTradeable
}
if currentDungeonDiscardStackLocked(stack) {
return false, errCurrentDungeonDiscardItemLocked
}
if currentDungeonDiscardStackExpired(stack, now) {
return false, errCurrentDungeonDiscardItemExpired
}
if int64(request.Count) > stack.Count ||
(definition.Kind == dungeonDropItemEquipment && (request.Count != 1 || stack.Count != 1)) {
return false, errCurrentDungeonDiscardCountInvalid
}
removedStack = cloneRuntimeDungeonDiscardStack(stack)
removedStack.Count = int64(request.Count)
sceneBody, sceneEntry, err = buildCurrentDungeonDiscardSceneBody(
currentSceneActorObjectKey(session.selectedCharacterID),
request.PositionX,
request.PositionY,
objectKey,
sceneSlot,
removedStack,
definition,
request.Count,
currentDungeonDiscardSceneMode,
)
if err != nil {
return false, err
}
remaining = stack.Count - int64(request.Count)
if remaining > 0 {
stack.Count = remaining
entry := currentItemListEntryFromStack(request.ListType, request.SourceSlot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
itemUpdate = entry
slots[key] = stack
return true, nil
}
itemUpdate.patchCore(request.SourceSlot, math.MaxUint32, 0)
delete(slots, key)
return true, nil
},
})
if errors.Is(err, dnfdungeon.ErrCharacterNotFound) {
err = errCurrentDungeonDiscardRuntimeMissing
} else if errors.Is(err, dnfdungeon.ErrInventoryNotFound) {
err = errCurrentDungeonDiscardInventoryMissing
}
if err != nil {
s.logGameEvent(session, "game-dungeon-discard-blocked",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"item_id", itemID,
"source_slot", request.SourceSlot,
"count", request.Count,
"account_owned", accountOwned,
"reason", "authoritative_inventory_transaction_rejected",
"error", err)
return true, nil
}
qualitySeed := uint32(0)
if definition.Kind == dungeonDropItemEquipment {
qualitySeed = binary.LittleEndian.Uint32(sceneEntry.data[0x06:0x0A])
}
drop := &runtimeDungeonDrop{
ObjectKey: objectKey,
SceneSlot: sceneSlot,
Item: definition,
Amount: request.Count,
QualitySeed: qualitySeed,
DiscardOrigin: &runtimeDungeonDiscardOrigin{
ListType: request.ListType,
SourceSlot: request.SourceSlot,
AccountOwned: accountOwned,
Stack: cloneRuntimeDungeonDiscardStack(removedStack),
},
OwnerActorObjectKey: currentSceneActorObjectKey(session.selectedCharacterID),
Room: runtimeDungeonRoomKeyFromScene(scene),
Status: runtimeDungeonDropAvailable,
}
if err := owner.registerBatch([]*runtimeDungeonDrop{drop}, sceneSlot+1); err != nil {
s.logGameEvent(session, "game-dungeon-discard-runtime-register-failed-after-commit",
"item_id", itemID,
"source_slot", request.SourceSlot,
"count", request.Count,
"object_key", objectKey,
"scene_slot", sceneSlot,
"error", err)
return true, err
}
runtime.DropOwner = owner
runtime.NextObjectKey = objectKey + 1
s.logGameEvent(session, "game-dungeon-discard-committed",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"item_id", itemID,
"source_slot", request.SourceSlot,
"count", request.Count,
"remaining", remaining,
"account_owned", accountOwned,
"attach_type", definition.AttachType,
"object_key", objectKey,
"scene_slot", sceneSlot,
"position_x", request.PositionX,
"position_y", request.PositionY,
"ack_opcode", currentDungeonDiscardOpcode,
"scene_opcode", currentDungeonDiscardSceneOpcode)
if err := s.sendGameUpperSuccess(session, currentDungeonDiscardOpcode, request.AckPayload); err != nil {
return true, err
}
// The current client keeps the source slot and dungeon item manager in a
// pending-discard state until class-1 op47 succeeds. Creating the class-0
// op40 ground object before that ACK leaves the new object outside the
// native pickup candidates and poisons later drops in the same room.
if err := s.sendGameUpperRawClass(
session,
currentDungeonDiscardSceneOpcode,
sceneBody,
0,
); err != nil {
return true, err
}
itemUpdateBody := buildCurrentItemUpdateBody(request.ListType, []currentItemListEntry{itemUpdate})
s.logGameEvent(session, "game-dungeon-discard-item-update-send",
"item_id", itemID,
"source_slot", request.SourceSlot,
"remaining", remaining,
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
"classification", 0,
"entry_count", 1,
"body_len", len(itemUpdateBody),
"body_source", "authoritative_post_commit_inventory_op14_raw77")
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
itemUpdateBody,
0,
); err != nil {
return true, err
}
return true, nil
}
func restoreCurrentDungeonDiscardedItem(
ctx context.Context,
uow dnfrepo.AccountCharacterAssetUnitOfWork,
accountID string,
characterID string,
origin runtimeDungeonDiscardOrigin,
definition dungeonDropItemDefinition,
now time.Time,
) (uint16, currentItemListEntry, error) {
if uow == nil || strings.TrimSpace(accountID) == "" || strings.TrimSpace(characterID) == "" ||
origin.ListType != dnfrepo.MainInventoryListType || origin.SourceSlot < 0 ||
origin.Stack.ItemID <= 0 || origin.Stack.Count <= 0 ||
uint64(origin.Stack.ItemID) > math.MaxUint32 ||
uint32(origin.Stack.ItemID) != definition.ItemID {
return 0, currentItemListEntry{}, errCurrentDungeonDiscardTransaction
}
if ctx == nil {
ctx = context.Background()
}
if now.IsZero() {
now = time.Now().UTC()
}
var (
destination uint16
itemUpdate currentItemListEntry
)
owner, err := dnfdungeon.NewOwner(dnfrepo.Group{AccountAssets: uow})
if err != nil {
return 0, currentItemListEntry{}, errCurrentDungeonDiscardTransaction
}
err = owner.MutateOwnedInventory(ctx, dnfdungeon.OwnedInventoryMutationCommand{
AccountID: accountID,
CharacterID: characterID,
AccountOwned: origin.AccountOwned,
UpdatedAt: now,
Apply: func(slots map[string]dnfrepo.ItemStack) (bool, error) {
stack := cloneRuntimeDungeonDiscardStack(origin.Stack)
key := currentDungeonPickupMainSlotKey(origin.SourceSlot)
if origin.AccountOwned {
if !dnfrepo.IsAccountSharedInventorySlot(origin.ListType, origin.SourceSlot) {
return false, errCurrentDungeonDiscardRestoreConflict
}
key = dnfrepo.AccountSharedInventorySlotKey(origin.SourceSlot)
}
if existing, occupied := slots[key]; occupied {
stack, err = mergeCurrentDungeonDiscardOrigin(slots, existing, stack, definition)
if err != nil {
return false, err
}
}
entry := currentItemListEntryFromStack(origin.ListType, origin.SourceSlot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
itemUpdate = entry
slots[key] = stack
destination = uint16(origin.SourceSlot)
return true, nil
},
})
if errors.Is(err, dnfdungeon.ErrCharacterNotFound) {
err = errCurrentDungeonDiscardRuntimeMissing
} else if errors.Is(err, dnfdungeon.ErrInventoryNotFound) {
err = errCurrentDungeonDiscardInventoryMissing
}
if err != nil {
return 0, currentItemListEntry{}, err
}
return destination, itemUpdate, nil
}
func mergeCurrentDungeonDiscardOrigin(
slots map[string]dnfrepo.ItemStack,
existing dnfrepo.ItemStack,
returned dnfrepo.ItemStack,
definition dungeonDropItemDefinition,
) (dnfrepo.ItemStack, error) {
if definition.Kind != dungeonDropItemStackable ||
existing.ItemID != returned.ItemID ||
existing.Bind != returned.Bind ||
existing.Bind ||
currentDungeonDiscardStackLocked(existing) ||
currentItemListStackExpire(existing) != currentItemListStackExpire(returned) ||
existing.Count <= 0 || returned.Count <= 0 ||
existing.Count > math.MaxInt64-returned.Count {
return dnfrepo.ItemStack{}, errCurrentDungeonDiscardRestoreConflict
}
// 归还目标是这件道具原来那一行,但封顶仍然按"这个容器里该道具总量"算:
// PVF [stack limit] 同时是单行上限和容器总量上限。
if definition.StackLimit > 0 && (existing.Count > definition.StackLimit-returned.Count ||
returned.Count > dnfrepo.ContainerItemSpace(
slots,
dnfrepo.MainInventoryListType,
returned.ItemID,
definition.StackLimit,
)) {
return dnfrepo.ItemStack{}, errCurrentDungeonDiscardRestoreConflict
}
existing = cloneRuntimeDungeonDiscardStack(existing)
existing.Count += returned.Count
return existing, nil
}