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,599 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user