服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1642 lines
58 KiB
Go
1642 lines
58 KiB
Go
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"
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfepicbook "longheng.io/server/internal/modules/dnf/epicbook"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
// Opcode 39 is directional: C2S op39 reports monster death, while current
|
|
// class0/S2C op39 is the ordinary scene-item pickup result.
|
|
currentDungeonPickupResultOpcode uint16 = 39
|
|
currentDungeonPickupResultSize = 19
|
|
currentDungeonGoldPickupEffectOpcode uint16 = 557
|
|
currentDungeonGoldPickupEffectSize = 2
|
|
currentDungeonPickupQuickSlotStart = int16(3)
|
|
currentDungeonPickupQuickSlotEnd = int16(8)
|
|
|
|
// Main inventory standard categories (equipment 9-64, material 65-120,
|
|
// consumable 121-176, quest 177-232) each span 7 rows of 8 slots. New
|
|
// characters unlock the first 4 rows (32 slots); the cash-shop expansion
|
|
// voucher unlocks one row (8 slots) per purchase, up to 3 rows (24 slots).
|
|
// Locked expansion slots must never be used as placement targets, otherwise
|
|
// items land in rows the client does not render.
|
|
currentMainInventoryBaseSlotCount int16 = 32
|
|
currentMainInventoryCategorySlotCount int16 = 56
|
|
)
|
|
|
|
// currentMainInventoryUnlockedEnd returns the last unlocked slot of a main
|
|
// inventory standard category. slotStart is the category's PVF first slot
|
|
// (equipment 9, material 65, consumable 121, quest 177); mainSlotCount is
|
|
// CharacterContainerState.MainSlotCount (0/8/16/24, i.e. 0/1/2/3 expanded rows).
|
|
func currentMainInventoryUnlockedEnd(slotStart int16, mainSlotCount uint16) int16 {
|
|
return slotStart + currentMainInventoryBaseSlotCount - 1 + int16(mainSlotCount)
|
|
}
|
|
|
|
// limitCurrentMainInventoryDefinitionSlots clamps a main-inventory standard
|
|
// category's SlotEnd to the currently unlocked last slot so locked expansion
|
|
// slots are never valid placement targets. Non-56-slot ranges such as the
|
|
// quick slots (3-8) or special materials (345-359) are left unchanged.
|
|
// currentMainInventoryClampedSlotEnd returns slotEnd clamped to the currently
|
|
// unlocked last slot when the range is a main-inventory standard category.
|
|
func currentMainInventoryClampedSlotEnd(slotStart, slotEnd int16, mainSlotCount uint16) int16 {
|
|
if slotStart >= currentDungeonPickupQuickSlotStart &&
|
|
slotEnd-slotStart+1 == currentMainInventoryCategorySlotCount {
|
|
unlockedEnd := currentMainInventoryUnlockedEnd(slotStart, mainSlotCount)
|
|
if slotEnd > unlockedEnd {
|
|
return unlockedEnd
|
|
}
|
|
}
|
|
return slotEnd
|
|
}
|
|
|
|
// limitCurrentMainInventoryDefinitionSlots clamps a main-inventory standard
|
|
// category's SlotEnd to the currently unlocked last slot so locked expansion
|
|
// slots are never valid placement targets. Non-56-slot ranges such as the
|
|
// quick slots (3-8) or special materials (345-359) are left unchanged.
|
|
func limitCurrentMainInventoryDefinitionSlots(definition dungeonDropItemDefinition, mainSlotCount uint16) dungeonDropItemDefinition {
|
|
definition.SlotEnd = currentMainInventoryClampedSlotEnd(definition.SlotStart, definition.SlotEnd, mainSlotCount)
|
|
return definition
|
|
}
|
|
|
|
func buildCurrentEpicBookPickupResultBody(dropObjectKey uint32, pickerActorObjectKey uint16, temporarySlot uint16) ([]byte, error) {
|
|
if dropObjectKey == 0 || pickerActorObjectKey == 0 {
|
|
return nil, fmt.Errorf("%w: epic fragment drop=%d picker=%d", errDungeonPickupResponseInvalid, dropObjectKey, pickerActorObjectKey)
|
|
}
|
|
if temporarySlot > 0 {
|
|
return buildCurrentDungeonNormalPickupResultBody(dropObjectKey, pickerActorObjectKey, temporarySlot)
|
|
}
|
|
var writer packetWriter
|
|
writer.writeUint32(dropObjectKey)
|
|
writer.writeUint16(pickerActorObjectKey)
|
|
writer.writeBytes(make([]byte, 8))
|
|
writer.writeUint16(0)
|
|
writer.writeUint16(0)
|
|
writer.writeByte(0)
|
|
body := writer.bytes()
|
|
if len(body) != currentDungeonPickupResultSize {
|
|
return nil, fmt.Errorf("%w: epic fragment body_len=%d", errDungeonPickupResponseInvalid, len(body))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildCurrentEpicBookTemporarySlotClearBody(temporarySlot uint16) ([]byte, error) {
|
|
if temporarySlot == 0 || temporarySlot > math.MaxInt16 {
|
|
return nil, fmt.Errorf("%w: temporary_slot=%d", errDungeonPickupResponseInvalid, temporarySlot)
|
|
}
|
|
var empty currentItemListEntry
|
|
empty.patchCore(int16(temporarySlot), math.MaxUint32, 0)
|
|
return buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, []currentItemListEntry{empty}), nil
|
|
}
|
|
|
|
func currentEpicBookTemporarySlot(inventory dnfrepo.InventoryRecord, first, last int16) (uint16, bool) {
|
|
if first <= 0 || last < first {
|
|
return 0, false
|
|
}
|
|
for slot := first; slot <= last; slot++ {
|
|
stack, occupied := inventory.Slots[currentDungeonPickupMainSlotKey(slot)]
|
|
if !occupied || stack.ItemID <= 0 || stack.Count <= 0 {
|
|
return uint16(slot), true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
var (
|
|
errDungeonPickupRuntimeMissing = errors.New("dnf dungeon pickup runtime is missing")
|
|
errDungeonPickupTransactionMissing = errors.New("dnf dungeon pickup inventory transaction is missing")
|
|
errDungeonPickupInventoryMissing = errors.New("dnf dungeon pickup inventory record is missing")
|
|
errDungeonPickupInventoryFull = errors.New("dnf dungeon pickup inventory category is full")
|
|
errDungeonPickupItemInvalid = errors.New("dnf dungeon pickup item is invalid")
|
|
errDungeonPickupStackLimit = errors.New("dnf dungeon pickup exceeds real PVF stack limit")
|
|
errDungeonPickupResponseInvalid = errors.New("dnf dungeon pickup response values are invalid")
|
|
errDungeonPickupDropStateInvalid = errors.New("dnf dungeon pickup drop state is invalid")
|
|
)
|
|
|
|
type currentDungeonPickupKind byte
|
|
|
|
const (
|
|
currentDungeonPickupItem currentDungeonPickupKind = iota + 1
|
|
currentDungeonPickupGold
|
|
currentDungeonPickupDiscardRestore
|
|
currentDungeonPickupEpicFragment
|
|
)
|
|
|
|
type currentDungeonPickupPlan struct {
|
|
kind currentDungeonPickupKind
|
|
runtime *runtimeDungeonState
|
|
drop *runtimeDungeonDrop
|
|
dropSnapshot runtimeDungeonDrop
|
|
attempt uint64
|
|
request dungeoncmd.GetItemRequest
|
|
room runtimeDungeonRoomKey
|
|
roomLabel string
|
|
mapID int64
|
|
pickerActorObjectKey uint16
|
|
characterID uint16
|
|
characterGeneration uint64
|
|
accountID string
|
|
repositories dnfrepo.Group
|
|
epicEntry dnfepicbook.Entry
|
|
}
|
|
|
|
type currentDungeonPickupResult struct {
|
|
committed bool
|
|
destinationSlot uint16
|
|
goldAfter int64
|
|
responseBody []byte
|
|
itemUpdateBody []byte
|
|
goldEffectBody []byte
|
|
epicClearBody []byte
|
|
epicSyncBody []byte
|
|
assetOwner string
|
|
failureReason string
|
|
responseReason string
|
|
elapsed time.Duration
|
|
err error
|
|
}
|
|
|
|
func buildCurrentDungeonNormalPickupResultBody(
|
|
dropObjectKey uint32,
|
|
pickerActorObjectKey uint16,
|
|
destinationSlot uint16,
|
|
) ([]byte, error) {
|
|
if dropObjectKey == 0 || pickerActorObjectKey == 0 || destinationSlot == 0 {
|
|
return nil, fmt.Errorf(
|
|
"%w: drop=%d picker=%d destination=%d",
|
|
errDungeonPickupResponseInvalid,
|
|
dropObjectKey,
|
|
pickerActorObjectKey,
|
|
destinationSlot,
|
|
)
|
|
}
|
|
var writer packetWriter
|
|
writer.writeUint32(dropObjectKey)
|
|
writer.writeUint16(pickerActorObjectKey)
|
|
writer.writeBytes(make([]byte, 8)) // read by current EXE; semantics unproved, default constructor state
|
|
writer.writeUint16(pickerActorObjectKey)
|
|
writer.writeUint16(destinationSlot)
|
|
writer.writeByte(0) // read by current EXE; not consumed by the visible permission/UI path
|
|
body := writer.bytes()
|
|
if len(body) != currentDungeonPickupResultSize {
|
|
return nil, fmt.Errorf("%w: body_len=%d", errDungeonPickupResponseInvalid, len(body))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildCurrentDungeonGoldPickupResultBody(
|
|
dropObjectKey uint32,
|
|
pickerActorObjectKey uint16,
|
|
) ([]byte, error) {
|
|
if dropObjectKey == 0 || pickerActorObjectKey == 0 {
|
|
return nil, fmt.Errorf(
|
|
"%w: drop=%d picker=%d",
|
|
errDungeonPickupResponseInvalid,
|
|
dropObjectKey,
|
|
pickerActorObjectKey,
|
|
)
|
|
}
|
|
var writer packetWriter
|
|
writer.writeUint32(dropObjectKey)
|
|
writer.writeUint16(pickerActorObjectKey)
|
|
writer.writeByte(1) // retained compatibility tail; current op39 itself only consumes the object key
|
|
writer.writeBytes(make([]byte, 7))
|
|
writer.writeUint16(pickerActorObjectKey)
|
|
writer.writeUint16(0) // gold has no destination inventory slot
|
|
writer.writeByte(1) // retained compatibility tail; op557 owns the current-client gold effect
|
|
body := writer.bytes()
|
|
if len(body) != currentDungeonPickupResultSize {
|
|
return nil, fmt.Errorf("%w: body_len=%d", errDungeonPickupResponseInvalid, len(body))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func buildCurrentDungeonGoldPickupEffectBody(amount uint32) ([]byte, error) {
|
|
if amount == 0 || amount > math.MaxUint16 {
|
|
return nil, fmt.Errorf(
|
|
"%w: gold_pickup_amount=%d",
|
|
errDungeonPickupResponseInvalid,
|
|
amount,
|
|
)
|
|
}
|
|
var writer packetWriter
|
|
writer.writeUint16(uint16(amount))
|
|
body := writer.bytes()
|
|
if len(body) != currentDungeonGoldPickupEffectSize {
|
|
return nil, fmt.Errorf("%w: gold_effect_body_len=%d", errDungeonPickupResponseInvalid, len(body))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// handleCurrentDungeonPickup validates and atomically claims exactly the ground
|
|
// object named by the client's op43 request. Every request therefore preserves
|
|
// stock manual-pickup behavior and leaves all other room drops on the ground.
|
|
func (s *Service) handleCurrentDungeonPickup(session *gameSession, body []byte) error {
|
|
request, err := dungeoncmd.DecodeGetItemRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_op43_ordinary_request_malformed",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
if request.FixedZero != 0 {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"fixed_byte", request.FixedZero,
|
|
"reason", "current_exe_op43_fixed_zero_mismatch")
|
|
return nil
|
|
}
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
|
|
session.dungeon.mu.Lock()
|
|
locked := true
|
|
defer func() {
|
|
if locked {
|
|
session.dungeon.mu.Unlock()
|
|
}
|
|
}()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || runtime.Room == nil || runtime.DropOwner == nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"reason", "dungeon_drop_runtime_missing",
|
|
"error", errDungeonPickupRuntimeMissing)
|
|
return nil
|
|
}
|
|
scene, ok := runtime.Session.Scene()
|
|
if !ok {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"reason", "dungeon_scene_missing",
|
|
"error", errDungeonPickupRuntimeMissing)
|
|
return nil
|
|
}
|
|
pickerActorObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
drop, err := runtime.DropOwner.owned(
|
|
request.DropObjectKey,
|
|
runtimeDungeonRoomKeyFromScene(scene),
|
|
pickerActorObjectKey,
|
|
)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", request.DropObjectKey,
|
|
"picker_actor_object_key", pickerActorObjectKey,
|
|
"reason", "runtime_drop_owner_rejected",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
if drop.Status == runtimeDungeonDropConsumed {
|
|
if len(drop.PickupResponseBody) != currentDungeonPickupResultSize {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"reason", "consumed_drop_response_missing",
|
|
"error", fmt.Errorf("%w: response_len=%d", errDungeonPickupDropStateInvalid, len(drop.PickupResponseBody)))
|
|
return nil
|
|
}
|
|
responseBody := append([]byte(nil), drop.PickupResponseBody...)
|
|
itemUpdateBody := append([]byte(nil), drop.PickupItemUpdateBody...)
|
|
epicClearBody := append([]byte(nil), drop.PickupEpicBookClearBody...)
|
|
epicSyncBody := append([]byte(nil), drop.PickupEpicBookBody...)
|
|
goldAfter := drop.GoldAfter
|
|
isGold := drop.isGold()
|
|
s.logGameEvent(session, "game-dungeon-pickup-replayed",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"gold_after", goldAfter,
|
|
"destination_slot", drop.DestinationSlot,
|
|
"request_context", request.ObjectContext,
|
|
"response_opcode", currentDungeonPickupResultOpcode)
|
|
session.dungeon.mu.Unlock()
|
|
locked = false
|
|
return s.sendCurrentDungeonPickupProjection(
|
|
session,
|
|
responseBody,
|
|
nil,
|
|
itemUpdateBody,
|
|
epicClearBody,
|
|
epicSyncBody,
|
|
isGold,
|
|
goldAfter,
|
|
true,
|
|
)
|
|
}
|
|
if drop.Status == runtimeDungeonDropPending {
|
|
s.logGameEvent(session, "game-dungeon-pickup-pending-replay",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"attempt", drop.PickupAttempt,
|
|
"reason", "repository_commit_in_progress_without_session_event_loop_ownership")
|
|
return nil
|
|
}
|
|
if drop.Status != runtimeDungeonDropAvailable {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"status", drop.Status,
|
|
"reason", "runtime_drop_state_invalid",
|
|
"error", errDungeonPickupDropStateInvalid)
|
|
return nil
|
|
}
|
|
|
|
repositories, repositoriesOK := s.repositoryGroup()
|
|
kind := currentDungeonPickupItem
|
|
missingReason := "character_items_transaction_missing"
|
|
var epicEntry dnfepicbook.Entry
|
|
if drop.isGold() {
|
|
kind = currentDungeonPickupGold
|
|
missingReason = "character_gold_transaction_missing"
|
|
repositoriesOK = repositoriesOK && repositories.CharacterAssets != nil
|
|
} else if drop.DiscardOrigin != nil {
|
|
kind = currentDungeonPickupDiscardRestore
|
|
missingReason = "discard_origin_account_assets_transaction_missing"
|
|
repositoriesOK = repositoriesOK && repositories.AccountAssets != nil
|
|
} else if repositoriesOK && repositories.EpicBookCraft != nil {
|
|
if catalog, catalogErr := s.currentEpicBookCatalog(); catalogErr == nil {
|
|
if entry, isFragment := catalog.EntryByFragment(drop.Item.ItemID); isFragment {
|
|
kind = currentDungeonPickupEpicFragment
|
|
epicEntry = entry
|
|
missingReason = "epic_book_fragment_transaction_missing"
|
|
}
|
|
}
|
|
if kind == currentDungeonPickupItem {
|
|
repositoriesOK = repositories.CharacterItems != nil
|
|
}
|
|
} else {
|
|
repositoriesOK = repositoriesOK && repositories.CharacterItems != nil
|
|
}
|
|
if !repositoriesOK {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"reason", missingReason,
|
|
"error", errDungeonPickupTransactionMissing)
|
|
return nil
|
|
}
|
|
|
|
dropSnapshot := *drop
|
|
if drop.DiscardOrigin != nil {
|
|
discardOrigin := *drop.DiscardOrigin
|
|
dropSnapshot.DiscardOrigin = &discardOrigin
|
|
}
|
|
drop.PickupAttempt++
|
|
if drop.PickupAttempt == 0 {
|
|
drop.PickupAttempt++
|
|
}
|
|
drop.Status = runtimeDungeonDropPending
|
|
plan := currentDungeonPickupPlan{
|
|
kind: kind,
|
|
runtime: runtime,
|
|
drop: drop,
|
|
dropSnapshot: dropSnapshot,
|
|
attempt: drop.PickupAttempt,
|
|
request: request,
|
|
room: runtimeDungeonRoomKeyFromScene(scene),
|
|
roomLabel: scene.Coordinate.String(),
|
|
mapID: int64(scene.Map.Map.ID),
|
|
pickerActorObjectKey: pickerActorObjectKey,
|
|
characterID: session.selectedCharacterID,
|
|
characterGeneration: session.characterGeneration,
|
|
accountID: strings.TrimSpace(s.accountIDForSession(session)),
|
|
repositories: repositories,
|
|
epicEntry: epicEntry,
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-pickup-pending",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", plan.roomLabel,
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"attempt", plan.attempt,
|
|
"persistence", "off_session_event_loop")
|
|
session.dungeon.mu.Unlock()
|
|
locked = false
|
|
|
|
if session.events != nil && session.events.loop != nil && session.events.active.Load() {
|
|
go s.runCurrentDungeonPickupPersistence(session, plan)
|
|
return nil
|
|
}
|
|
return s.completeCurrentDungeonPickup(session, plan, s.executeCurrentDungeonPickup(session, plan))
|
|
}
|
|
|
|
func (s *Service) runCurrentDungeonPickupPersistence(session *gameSession, plan currentDungeonPickupPlan) {
|
|
result := s.executeCurrentDungeonPickup(session, plan)
|
|
err := s.postGameSessionCharacterEvent(
|
|
session,
|
|
"dungeon-pickup-persistence-complete",
|
|
plan.characterID,
|
|
plan.characterGeneration,
|
|
func() error {
|
|
return s.completeCurrentDungeonPickup(session, plan, result)
|
|
},
|
|
)
|
|
if err != nil && !isClosedGameSessionEventError(err) {
|
|
s.logPacketEvent("game-session-event-submit-failed",
|
|
"conn_id", session.connID,
|
|
"source", "dungeon-pickup-persistence-complete",
|
|
"char_id", plan.characterID,
|
|
"character_generation", plan.characterGeneration,
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"attempt", plan.attempt,
|
|
"error", err)
|
|
}
|
|
}
|
|
|
|
func (s *Service) executeCurrentDungeonPickup(
|
|
session *gameSession,
|
|
plan currentDungeonPickupPlan,
|
|
) (result currentDungeonPickupResult) {
|
|
started := time.Now()
|
|
defer func() { result.elapsed = time.Since(started) }()
|
|
baseContext := context.Background()
|
|
if session != nil && session.ctx != nil {
|
|
baseContext = session.ctx
|
|
}
|
|
ctx, cancel := context.WithTimeout(baseContext, currentDungeonPickupWriteTimeout)
|
|
defer cancel()
|
|
characterID := strconv.Itoa(int(plan.characterID))
|
|
now := time.Now().UTC()
|
|
// 拾取落点必须落在已解锁的背包行里。角色默认只有 4 行(32 格), 另外 3 行要用
|
|
// 背包扩展券解锁(每张 1 行, 最多 3 行); 未解锁的格子客户端不渲染, 落进去的物品
|
|
// 看起来就"消失"了。赛丽亚商店/任务奖励/通关翻牌都走
|
|
// limitCurrentMainInventoryDefinitionSlots, 只有拾取这条路径漏了(用户 2026-09-12
|
|
// 报"副本内拾取物品会跑到未解锁的槽位")。
|
|
mainSlotCount := s.currentInventoryPlacementMainSlotCount(ctx, nil, characterID)
|
|
dropItem := limitCurrentMainInventoryDefinitionSlots(plan.dropSnapshot.Item, mainSlotCount)
|
|
|
|
switch plan.kind {
|
|
case currentDungeonPickupGold:
|
|
result.assetOwner = "character_assets_uow_gold"
|
|
result.failureReason = "character_gold_transaction_failed_drop_remains_available"
|
|
result.responseReason = "gold_pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant"
|
|
result.goldAfter, result.err = grantCurrentDungeonPickupGold(
|
|
ctx,
|
|
plan.repositories.CharacterAssets,
|
|
characterID,
|
|
plan.dropSnapshot.Amount,
|
|
now,
|
|
)
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
result.committed = true
|
|
result.responseBody, result.err = buildCurrentDungeonGoldPickupResultBody(
|
|
plan.dropSnapshot.ObjectKey,
|
|
plan.pickerActorObjectKey,
|
|
)
|
|
if result.err == nil {
|
|
result.goldEffectBody, _ = buildCurrentDungeonGoldPickupEffectBody(plan.dropSnapshot.Amount)
|
|
result.itemUpdateBody = buildCurrentGoldStateBody(result.goldAfter)
|
|
}
|
|
return result
|
|
|
|
case currentDungeonPickupDiscardRestore:
|
|
result.assetOwner = "discard_origin_account_assets_uow"
|
|
result.failureReason = "discard_origin_restore_transaction_failed_drop_remains_available"
|
|
result.responseReason = "discard_origin_pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant"
|
|
origin := plan.dropSnapshot.DiscardOrigin
|
|
if origin == nil {
|
|
result.err = errDungeonPickupDropStateInvalid
|
|
return result
|
|
}
|
|
var itemUpdate currentItemListEntry
|
|
result.destinationSlot, itemUpdate, result.err = restoreCurrentDungeonDiscardedItem(
|
|
ctx,
|
|
plan.repositories.AccountAssets,
|
|
plan.accountID,
|
|
characterID,
|
|
*origin,
|
|
plan.dropSnapshot.Item,
|
|
now,
|
|
)
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
result.committed = true
|
|
result.itemUpdateBody = buildCurrentItemUpdateBody(
|
|
origin.ListType,
|
|
[]currentItemListEntry{itemUpdate},
|
|
)
|
|
|
|
case currentDungeonPickupEpicFragment:
|
|
result.assetOwner = "epic_book_fragment_uow"
|
|
result.failureReason = "epic_book_fragment_transaction_failed_drop_remains_available"
|
|
result.responseReason = "epic_book_pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant"
|
|
if plan.repositories.EpicBookCraft == nil || plan.epicEntry.EquipmentID == 0 {
|
|
result.err = errDungeonPickupTransactionMissing
|
|
return result
|
|
}
|
|
if plan.repositories.Inventory != nil {
|
|
if inventory, found, loadErr := plan.repositories.Inventory.Load(ctx, characterID); loadErr == nil && found {
|
|
result.destinationSlot, _ = currentEpicBookTemporarySlot(
|
|
inventory,
|
|
plan.dropSnapshot.Item.SlotStart,
|
|
plan.dropSnapshot.Item.SlotEnd,
|
|
)
|
|
}
|
|
}
|
|
var balance uint32
|
|
balance, result.err = dnfepicbook.AddFragment(
|
|
ctx,
|
|
plan.repositories.EpicBookCraft,
|
|
plan.accountID,
|
|
characterID,
|
|
plan.epicEntry.EquipmentID,
|
|
plan.dropSnapshot.Amount,
|
|
now,
|
|
)
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
result.committed = true
|
|
result.responseBody, result.err = buildCurrentEpicBookPickupResultBody(
|
|
plan.dropSnapshot.ObjectKey,
|
|
plan.pickerActorObjectKey,
|
|
result.destinationSlot,
|
|
)
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
if result.destinationSlot > 0 {
|
|
result.epicClearBody, result.err = buildCurrentEpicBookTemporarySlotClearBody(result.destinationSlot)
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
}
|
|
result.epicSyncBody, result.err = buildCurrentEpicBookInfoBody(
|
|
1,
|
|
[]currentEpicBookBalanceRow{{FragmentID: plan.epicEntry.FragmentID, Balance: balance}},
|
|
)
|
|
return result
|
|
|
|
default:
|
|
var itemUpdate currentItemListEntry
|
|
if sharedSlot, shared := currentDungeonPickupAccountSharedSlot(plan.dropSnapshot.Item.ItemID); shared {
|
|
result.assetOwner = "account_shared_crystal_assets_uow"
|
|
result.failureReason = "account_shared_crystal_transaction_failed_drop_remains_available"
|
|
result.responseReason = "shared_crystal_pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant"
|
|
result.destinationSlot, itemUpdate, result.err = grantCurrentDungeonPickupAccountShared(
|
|
ctx,
|
|
plan.repositories.AccountAssets,
|
|
plan.accountID,
|
|
characterID,
|
|
dropItem,
|
|
plan.dropSnapshot.Amount,
|
|
sharedSlot,
|
|
now,
|
|
plan.dropSnapshot.QualitySeed,
|
|
)
|
|
} else {
|
|
result.assetOwner = "character_items_uow"
|
|
result.failureReason = "character_items_transaction_failed_drop_remains_available"
|
|
result.responseReason = "pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant"
|
|
result.destinationSlot, itemUpdate, result.err = s.grantCurrentDungeonPickupItem(
|
|
ctx,
|
|
plan.repositories.CharacterItems,
|
|
characterID,
|
|
dropItem,
|
|
plan.dropSnapshot.Amount,
|
|
now,
|
|
currentDungeonDiscardReservedSlots(plan.runtime),
|
|
plan.dropSnapshot.QualitySeed,
|
|
)
|
|
}
|
|
if result.err != nil {
|
|
return result
|
|
}
|
|
result.committed = true
|
|
// The op14 envelope must name the list the row was actually written to.
|
|
// A guild medal/gem announced on list 0 leaves the client showing it in
|
|
// the main bag until the next full list rebuild.
|
|
itemUpdateListType := dnfrepo.MainInventoryListType
|
|
if _, shared := currentDungeonPickupAccountSharedSlot(plan.dropSnapshot.Item.ItemID); !shared {
|
|
itemUpdateListType, _, _ = currentDungeonPickupListPlacement(plan.dropSnapshot.Item)
|
|
}
|
|
result.itemUpdateBody = buildCurrentItemUpdateBody(
|
|
itemUpdateListType,
|
|
[]currentItemListEntry{itemUpdate},
|
|
)
|
|
}
|
|
|
|
result.responseBody, result.err = buildCurrentDungeonNormalPickupResultBody(
|
|
plan.dropSnapshot.ObjectKey,
|
|
plan.pickerActorObjectKey,
|
|
result.destinationSlot,
|
|
)
|
|
return result
|
|
}
|
|
|
|
func (s *Service) completeCurrentDungeonPickup(
|
|
session *gameSession,
|
|
plan currentDungeonPickupPlan,
|
|
result currentDungeonPickupResult,
|
|
) error {
|
|
if session == nil || plan.drop == nil {
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
drop := plan.drop
|
|
if drop.Status != runtimeDungeonDropPending || drop.PickupAttempt != plan.attempt {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-pickup-completion-stale",
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"attempt", plan.attempt,
|
|
"current_attempt", drop.PickupAttempt,
|
|
"status", drop.Status,
|
|
"committed", result.committed,
|
|
"elapsed_ms", result.elapsed.Milliseconds())
|
|
return nil
|
|
}
|
|
if result.err != nil && !result.committed {
|
|
drop.Status = runtimeDungeonDropAvailable
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", plan.runtime.Dungeon.ID,
|
|
"room", plan.roomLabel,
|
|
"map_id", plan.mapID,
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"item_id", plan.dropSnapshot.Item.ItemID,
|
|
"amount", plan.dropSnapshot.Amount,
|
|
"attempt", plan.attempt,
|
|
"elapsed_ms", result.elapsed.Milliseconds(),
|
|
"reason", result.failureReason,
|
|
"error", result.err)
|
|
return nil
|
|
}
|
|
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = result.destinationSlot
|
|
drop.GoldAfter = result.goldAfter
|
|
drop.PickupResponseBody = append([]byte(nil), result.responseBody...)
|
|
drop.PickupItemUpdateBody = append([]byte(nil), result.itemUpdateBody...)
|
|
drop.PickupEpicBookClearBody = append([]byte(nil), result.epicClearBody...)
|
|
drop.PickupEpicBookBody = append([]byte(nil), result.epicSyncBody...)
|
|
currentSceneACK := false
|
|
if session.dungeon.runtime == plan.runtime && plan.runtime.DropOwner != nil &&
|
|
plan.runtime.DropOwner.byObjectKey[plan.dropSnapshot.ObjectKey] == drop {
|
|
if scene, ok := plan.runtime.Session.Scene(); ok {
|
|
currentSceneACK = runtimeDungeonRoomKeyFromScene(scene) == plan.room
|
|
}
|
|
}
|
|
session.dungeon.mu.Unlock()
|
|
|
|
if result.err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-response-blocked-after-commit",
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"item_id", plan.dropSnapshot.Item.ItemID,
|
|
"amount", plan.dropSnapshot.Amount,
|
|
"destination_slot", result.destinationSlot,
|
|
"gold_after", result.goldAfter,
|
|
"attempt", plan.attempt,
|
|
"elapsed_ms", result.elapsed.Milliseconds(),
|
|
"reason", result.responseReason,
|
|
"error", result.err)
|
|
return nil
|
|
}
|
|
|
|
s.logGameEvent(session, "game-dungeon-pickup-accepted",
|
|
"dungeon_id", plan.runtime.Dungeon.ID,
|
|
"room", plan.roomLabel,
|
|
"map_id", plan.mapID,
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"scene_slot", plan.dropSnapshot.SceneSlot,
|
|
"item_id", plan.dropSnapshot.Item.ItemID,
|
|
"amount", plan.dropSnapshot.Amount,
|
|
"destination_slot", result.destinationSlot,
|
|
"gold_after", result.goldAfter,
|
|
"picker_actor_object_key", plan.pickerActorObjectKey,
|
|
"request_context", plan.request.ObjectContext,
|
|
"request_player_x", plan.request.PlayerX,
|
|
"request_player_y", plan.request.PlayerY,
|
|
"request_token0", plan.request.Token0,
|
|
"request_drop_x", plan.request.DropX,
|
|
"request_drop_y", plan.request.DropY,
|
|
"request_token1", plan.request.Token1,
|
|
"request_token2", plan.request.Token2,
|
|
"attempt", plan.attempt,
|
|
"elapsed_ms", result.elapsed.Milliseconds(),
|
|
"response_opcode", currentDungeonPickupResultOpcode,
|
|
"response_body_len", len(result.responseBody),
|
|
"asset_owner", result.assetOwner,
|
|
"item_update_opcode", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
"item_update_body_len", len(result.itemUpdateBody),
|
|
"scene_ack", currentSceneACK)
|
|
if !currentSceneACK {
|
|
s.logGameEvent(session, "game-dungeon-pickup-scene-ack-suppressed",
|
|
"drop_object_key", plan.dropSnapshot.ObjectKey,
|
|
"attempt", plan.attempt,
|
|
"reason", "repository_commit_finished_after_dungeon_scene_changed")
|
|
}
|
|
return s.sendCurrentDungeonPickupProjection(
|
|
session,
|
|
result.responseBody,
|
|
result.goldEffectBody,
|
|
result.itemUpdateBody,
|
|
result.epicClearBody,
|
|
result.epicSyncBody,
|
|
plan.kind == currentDungeonPickupGold,
|
|
result.goldAfter,
|
|
currentSceneACK,
|
|
)
|
|
}
|
|
|
|
func (s *Service) sendCurrentDungeonPickupProjection(
|
|
session *gameSession,
|
|
responseBody []byte,
|
|
goldEffectBody []byte,
|
|
itemUpdateBody []byte,
|
|
epicClearBody []byte,
|
|
epicSyncBody []byte,
|
|
isGold bool,
|
|
goldAfter int64,
|
|
sendSceneACK bool,
|
|
) error {
|
|
if sendSceneACK {
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonPickupResultOpcode, responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if len(goldEffectBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonGoldPickupEffectOpcode, goldEffectBody, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if isGold && goldAfter > 0 && len(itemUpdateBody) == 0 {
|
|
itemUpdateBody = buildCurrentGoldStateBody(goldAfter)
|
|
}
|
|
if len(itemUpdateBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
itemUpdateBody,
|
|
0,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(epicClearBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
epicClearBody,
|
|
0,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(epicSyncBody) > 0 {
|
|
return s.sendGameUpperRawClass(session, currentEpicBookInfoWireOpcode, epicSyncBody, 0)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentDungeonSinglePickupLegacy(session *gameSession, body []byte) error {
|
|
request, err := dungeoncmd.DecodeGetItemRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_op43_ordinary_request_malformed",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
if request.FixedZero != 0 {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"fixed_byte", request.FixedZero,
|
|
"reason", "current_exe_op43_fixed_zero_mismatch")
|
|
return nil
|
|
}
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || runtime.Room == nil || runtime.DropOwner == nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"reason", "dungeon_drop_runtime_missing",
|
|
"error", errDungeonPickupRuntimeMissing)
|
|
return nil
|
|
}
|
|
scene, ok := runtime.Session.Scene()
|
|
if !ok {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", request.DropObjectKey,
|
|
"reason", "dungeon_scene_missing",
|
|
"error", errDungeonPickupRuntimeMissing)
|
|
return nil
|
|
}
|
|
pickerActorObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
drop, err := runtime.DropOwner.owned(
|
|
request.DropObjectKey,
|
|
runtimeDungeonRoomKeyFromScene(scene),
|
|
pickerActorObjectKey,
|
|
)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", request.DropObjectKey,
|
|
"picker_actor_object_key", pickerActorObjectKey,
|
|
"reason", "runtime_drop_owner_rejected",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
if drop.Status == runtimeDungeonDropConsumed {
|
|
if len(drop.PickupResponseBody) != currentDungeonPickupResultSize {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"reason", "consumed_drop_response_missing",
|
|
"error", fmt.Errorf("%w: response_len=%d", errDungeonPickupDropStateInvalid, len(drop.PickupResponseBody)))
|
|
return nil
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-pickup-replayed",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"gold_after", drop.GoldAfter,
|
|
"destination_slot", drop.DestinationSlot,
|
|
"request_context", request.ObjectContext,
|
|
"request_player_x", request.PlayerX,
|
|
"request_player_y", request.PlayerY,
|
|
"request_drop_x", request.DropX,
|
|
"request_drop_y", request.DropY,
|
|
"response_opcode", currentDungeonPickupResultOpcode)
|
|
if err := s.sendGameUpperRawClass(
|
|
session,
|
|
currentDungeonPickupResultOpcode,
|
|
append([]byte(nil), drop.PickupResponseBody...),
|
|
0,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if drop.isGold() && drop.GoldAfter > 0 {
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
buildCurrentGoldStateBody(drop.GoldAfter),
|
|
0,
|
|
)
|
|
}
|
|
if len(drop.PickupItemUpdateBody) > 0 {
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
append([]byte(nil), drop.PickupItemUpdateBody...),
|
|
0,
|
|
)
|
|
}
|
|
if len(drop.PickupEpicBookClearBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), append([]byte(nil), drop.PickupEpicBookClearBody...), 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(drop.PickupEpicBookBody) > 0 {
|
|
return s.sendGameUpperRawClass(session, currentEpicBookInfoWireOpcode, append([]byte(nil), drop.PickupEpicBookBody...), 0)
|
|
}
|
|
return nil
|
|
}
|
|
if drop.Status != runtimeDungeonDropAvailable {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"status", drop.Status,
|
|
"reason", "runtime_drop_state_invalid",
|
|
"error", errDungeonPickupDropStateInvalid)
|
|
return nil
|
|
}
|
|
if drop.isGold() {
|
|
responseBody, err := buildCurrentDungeonGoldPickupResultBody(drop.ObjectKey, pickerActorObjectKey)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"reason", "gold_pickup_response_invariant_failed_before_commit",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
goldEffectBody, err := buildCurrentDungeonGoldPickupEffectBody(drop.Amount)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-effect-skipped",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"reason", "current_exe_op557_u16_amount_out_of_range_absolute_op14_remains_authoritative",
|
|
"error", err)
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.CharacterAssets == nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"reason", "character_gold_transaction_missing",
|
|
"error", errDungeonPickupTransactionMissing)
|
|
return nil
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
goldAfter, err := grantCurrentDungeonPickupGold(
|
|
context.Background(),
|
|
repositories.CharacterAssets,
|
|
characterID,
|
|
drop.Amount,
|
|
time.Now().UTC(),
|
|
)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"reason", "character_gold_transaction_failed_drop_remains_available",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.GoldAfter = goldAfter
|
|
drop.PickupResponseBody = append([]byte(nil), responseBody...)
|
|
s.logGameEvent(session, "game-dungeon-pickup-accepted",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"scene_slot", drop.SceneSlot,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"gold_after", goldAfter,
|
|
"picker_actor_object_key", pickerActorObjectKey,
|
|
"request_context", request.ObjectContext,
|
|
"request_player_x", request.PlayerX,
|
|
"request_player_y", request.PlayerY,
|
|
"request_token0", request.Token0,
|
|
"request_drop_x", request.DropX,
|
|
"request_drop_y", request.DropY,
|
|
"request_token1", request.Token1,
|
|
"request_token2", request.Token2,
|
|
"response_opcode", currentDungeonPickupResultOpcode,
|
|
"response_body_len", len(responseBody),
|
|
"asset_owner", "character_assets_uow_gold")
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonPickupResultOpcode, responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if len(goldEffectBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonGoldPickupEffectOpcode, goldEffectBody, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
buildCurrentGoldStateBody(goldAfter),
|
|
0,
|
|
)
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if ok && repositories.EpicBookCraft != nil {
|
|
if catalog, catalogErr := s.currentEpicBookCatalog(); catalogErr == nil {
|
|
if entry, isFragment := catalog.EntryByFragment(drop.Item.ItemID); isFragment {
|
|
var temporarySlot uint16
|
|
if repositories.Inventory != nil {
|
|
if inventory, found, loadErr := repositories.Inventory.Load(context.Background(), strconv.Itoa(int(session.selectedCharacterID))); loadErr == nil && found {
|
|
temporarySlot, _ = currentEpicBookTemporarySlot(inventory, drop.Item.SlotStart, drop.Item.SlotEnd)
|
|
}
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
balance, commitErr := dnfepicbook.AddFragment(context.Background(), repositories.EpicBookCraft, accountID, characterID, entry.EquipmentID, drop.Amount, time.Now().UTC())
|
|
if commitErr != nil {
|
|
return nil
|
|
}
|
|
responseBody, buildErr := buildCurrentEpicBookPickupResultBody(drop.ObjectKey, pickerActorObjectKey, temporarySlot)
|
|
if buildErr != nil {
|
|
return buildErr
|
|
}
|
|
var clearBody []byte
|
|
if temporarySlot > 0 {
|
|
clearBody, buildErr = buildCurrentEpicBookTemporarySlotClearBody(temporarySlot)
|
|
if buildErr != nil {
|
|
return buildErr
|
|
}
|
|
}
|
|
syncBody, syncErr := buildCurrentEpicBookInfoBody(1, []currentEpicBookBalanceRow{{FragmentID: entry.FragmentID, Balance: balance}})
|
|
if syncErr != nil {
|
|
return syncErr
|
|
}
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = temporarySlot
|
|
drop.PickupResponseBody = append([]byte(nil), responseBody...)
|
|
drop.PickupEpicBookClearBody = append([]byte(nil), clearBody...)
|
|
drop.PickupEpicBookBody = append([]byte(nil), syncBody...)
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonPickupResultOpcode, responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if len(clearBody) > 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), clearBody, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.sendGameUpperRawClass(session, currentEpicBookInfoWireOpcode, syncBody, 0)
|
|
}
|
|
}
|
|
}
|
|
if drop.DiscardOrigin != nil {
|
|
if !ok || repositories.AccountAssets == nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"reason", "discard_origin_account_assets_transaction_missing",
|
|
"error", errDungeonPickupTransactionMissing)
|
|
return nil
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
destinationSlot, itemUpdate, err := restoreCurrentDungeonDiscardedItem(
|
|
context.Background(),
|
|
repositories.AccountAssets,
|
|
strings.TrimSpace(s.accountIDForSession(session)),
|
|
characterID,
|
|
*drop.DiscardOrigin,
|
|
drop.Item,
|
|
time.Now().UTC(),
|
|
)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"source_slot", drop.DiscardOrigin.SourceSlot,
|
|
"account_owned", drop.DiscardOrigin.AccountOwned,
|
|
"reason", "discard_origin_restore_transaction_failed_drop_remains_available",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
responseBody, err := buildCurrentDungeonNormalPickupResultBody(
|
|
drop.ObjectKey,
|
|
pickerActorObjectKey,
|
|
destinationSlot,
|
|
)
|
|
if err != nil {
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = destinationSlot
|
|
s.logGameEvent(session, "game-dungeon-pickup-response-blocked-after-commit",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"destination_slot", destinationSlot,
|
|
"reason", "discard_origin_pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = destinationSlot
|
|
drop.PickupResponseBody = append([]byte(nil), responseBody...)
|
|
itemUpdateBody := buildCurrentItemUpdateBody(
|
|
drop.DiscardOrigin.ListType,
|
|
[]currentItemListEntry{itemUpdate},
|
|
)
|
|
drop.PickupItemUpdateBody = append([]byte(nil), itemUpdateBody...)
|
|
s.logGameEvent(session, "game-dungeon-pickup-accepted",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"scene_slot", drop.SceneSlot,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"destination_slot", destinationSlot,
|
|
"picker_actor_object_key", pickerActorObjectKey,
|
|
"request_context", request.ObjectContext,
|
|
"request_player_x", request.PlayerX,
|
|
"request_player_y", request.PlayerY,
|
|
"request_drop_x", request.DropX,
|
|
"request_drop_y", request.DropY,
|
|
"response_opcode", currentDungeonPickupResultOpcode,
|
|
"response_body_len", len(responseBody),
|
|
"asset_owner", "discard_origin_account_assets_uow",
|
|
"item_update_opcode", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
"item_update_body_len", len(itemUpdateBody))
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonPickupResultOpcode, responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
itemUpdateBody,
|
|
0,
|
|
)
|
|
}
|
|
if !ok || repositories.CharacterItems == nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"reason", "character_items_transaction_missing",
|
|
"error", errDungeonPickupTransactionMissing)
|
|
return nil
|
|
}
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
destinationSlot, itemUpdate, err := s.grantCurrentDungeonPickupItem(
|
|
context.Background(),
|
|
repositories.CharacterItems,
|
|
characterID,
|
|
drop.Item,
|
|
drop.Amount,
|
|
time.Now().UTC(),
|
|
currentDungeonDiscardReservedSlots(runtime),
|
|
drop.QualitySeed,
|
|
)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-pickup-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"reason", "character_items_transaction_failed_drop_remains_available",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
responseBody, err := buildCurrentDungeonNormalPickupResultBody(
|
|
drop.ObjectKey,
|
|
pickerActorObjectKey,
|
|
destinationSlot,
|
|
)
|
|
if err != nil {
|
|
// The allocator only returns proved positive uint16 category slots, so
|
|
// this is an invariant failure after a committed transaction. Consume
|
|
// the scene object to prevent a duplicate grant even if no ACK can be sent.
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = destinationSlot
|
|
s.logGameEvent(session, "game-dungeon-pickup-response-blocked-after-commit",
|
|
"drop_object_key", drop.ObjectKey,
|
|
"item_id", drop.Item.ItemID,
|
|
"destination_slot", destinationSlot,
|
|
"reason", "pickup_response_invariant_failed_drop_consumed_to_prevent_duplicate_grant",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
drop.Status = runtimeDungeonDropConsumed
|
|
drop.DestinationSlot = destinationSlot
|
|
drop.PickupResponseBody = append([]byte(nil), responseBody...)
|
|
// Same rule as the ordinary pickup route: announce the row on the list it
|
|
// was written to, so a guild medal/gem does not appear in the main bag.
|
|
pickupListType, _, _ := currentDungeonPickupListPlacement(drop.Item)
|
|
itemUpdateBody := buildCurrentItemUpdateBody(
|
|
pickupListType,
|
|
[]currentItemListEntry{itemUpdate},
|
|
)
|
|
drop.PickupItemUpdateBody = append([]byte(nil), itemUpdateBody...)
|
|
s.logGameEvent(session, "game-dungeon-pickup-accepted",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"drop_object_key", drop.ObjectKey,
|
|
"scene_slot", drop.SceneSlot,
|
|
"item_id", drop.Item.ItemID,
|
|
"amount", drop.Amount,
|
|
"destination_slot", destinationSlot,
|
|
"picker_actor_object_key", pickerActorObjectKey,
|
|
"request_context", request.ObjectContext,
|
|
"request_player_x", request.PlayerX,
|
|
"request_player_y", request.PlayerY,
|
|
"request_token0", request.Token0,
|
|
"request_drop_x", request.DropX,
|
|
"request_drop_y", request.DropY,
|
|
"request_token1", request.Token1,
|
|
"request_token2", request.Token2,
|
|
"response_opcode", currentDungeonPickupResultOpcode,
|
|
"response_body_len", len(responseBody),
|
|
"asset_owner", "character_items_uow",
|
|
"item_update_opcode", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
"item_update_body_len", len(itemUpdateBody))
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonPickupResultOpcode, responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
return s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
|
itemUpdateBody,
|
|
0,
|
|
)
|
|
}
|
|
|
|
// currentDungeonDiscardReservedSlots 给出"本局里必须留空"的主背包格位:地上还躺着
|
|
// 属于这一格的弃置物,捡回来时服务端会把它放回被丢下的那一格。
|
|
//
|
|
// 弃置物换房间后依然有效(掉落回执要活到本局结束,见 runtimeDungeonDropOwner 的说明),
|
|
// 装备又不能并进已占用的行 —— 所以只要有一件新拾取物按"最小空格优先"占了这一格,那件
|
|
// 弃置物就再也捡不起来,会在副本里一直留到本局结束。2026-09-27 的线上取证就是这条:
|
|
// 超重时客户端把越线的装备丢回 43 格 → 自动拾取把新物品放进刚空出来的 43 格 →
|
|
// 地面对象 504 永久无法拾取(pickup-blocked / discard_origin_restore_...)。
|
|
//
|
|
// 这里不改"弃置物归还原格位"这条规则,只是把它记成"已许诺",让新行分配绕开。
|
|
// 集合按需构建:没有在途弃置物时返回 nil,不给普通拾取加分配。
|
|
func currentDungeonDiscardReservedSlots(runtime *runtimeDungeonState) map[int16]struct{} {
|
|
if runtime == nil || runtime.DropOwner == nil {
|
|
return nil
|
|
}
|
|
var reserved map[int16]struct{}
|
|
for _, drop := range runtime.DropOwner.byObjectKey {
|
|
if drop == nil || drop.Status != runtimeDungeonDropAvailable || drop.DiscardOrigin == nil {
|
|
continue
|
|
}
|
|
origin := drop.DiscardOrigin
|
|
if origin.ListType != dnfrepo.MainInventoryListType || origin.SourceSlot < 0 {
|
|
continue
|
|
}
|
|
if reserved == nil {
|
|
reserved = make(map[int16]struct{}, 1)
|
|
}
|
|
reserved[origin.SourceSlot] = struct{}{}
|
|
}
|
|
return reserved
|
|
}
|
|
|
|
func grantCurrentDungeonPickupItem(
|
|
ctx context.Context,
|
|
uow dnfrepo.CharacterItemUnitOfWork,
|
|
characterID string,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
now time.Time,
|
|
reservedSlots map[int16]struct{},
|
|
qualitySeed ...uint32,
|
|
) (uint16, currentItemListEntry, error) {
|
|
return grantCurrentDungeonPickupItemWithFinalizer(
|
|
ctx,
|
|
uow,
|
|
characterID,
|
|
definition,
|
|
amount,
|
|
now,
|
|
nil,
|
|
reservedSlots,
|
|
qualitySeed...,
|
|
)
|
|
}
|
|
|
|
type currentDungeonPickupStackFinalizer func(int16, dnfrepo.ItemStack) (dnfrepo.ItemStack, error)
|
|
|
|
func (s *Service) grantCurrentDungeonPickupItem(
|
|
ctx context.Context,
|
|
uow dnfrepo.CharacterItemUnitOfWork,
|
|
characterID string,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
now time.Time,
|
|
reservedSlots map[int16]struct{},
|
|
qualitySeed ...uint32,
|
|
) (uint16, currentItemListEntry, error) {
|
|
finalize, err := s.currentDungeonPickupCustomAbilityFinalizer(definition)
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, err
|
|
}
|
|
return grantCurrentDungeonPickupItemWithFinalizer(
|
|
ctx,
|
|
uow,
|
|
characterID,
|
|
definition,
|
|
amount,
|
|
now,
|
|
finalize,
|
|
reservedSlots,
|
|
qualitySeed...,
|
|
)
|
|
}
|
|
|
|
func grantCurrentDungeonPickupItemWithFinalizer(
|
|
ctx context.Context,
|
|
uow dnfrepo.CharacterItemUnitOfWork,
|
|
characterID string,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
now time.Time,
|
|
finalize currentDungeonPickupStackFinalizer,
|
|
reservedSlots map[int16]struct{},
|
|
qualitySeed ...uint32,
|
|
) (uint16, currentItemListEntry, error) {
|
|
if uow == nil || characterID == "" {
|
|
return 0, currentItemListEntry{}, errDungeonPickupTransactionMissing
|
|
}
|
|
if definition.ItemID == 0 || definition.SlotStart <= 0 || definition.SlotEnd < definition.SlotStart || amount == 0 {
|
|
return 0, currentItemListEntry{}, fmt.Errorf(
|
|
"%w: item=%d slots=%d..%d amount=%d",
|
|
errDungeonPickupItemInvalid,
|
|
definition.ItemID,
|
|
definition.SlotStart,
|
|
definition.SlotEnd,
|
|
amount,
|
|
)
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
var err error
|
|
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, err
|
|
}
|
|
owner, err := dnfdungeon.NewOwner(dnfrepo.Group{CharacterItems: uow})
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, errDungeonPickupTransactionMissing
|
|
}
|
|
placementListType, _, _ := currentDungeonPickupListPlacement(definition)
|
|
placement := currentDungeonPickupPlacement(definition, amount, qualitySeed...)
|
|
// 绕开"地上那件弃置物迟早要回来"的格位;没有在途弃置物时是 nil,落点规则不变。
|
|
placement.ReservedSlots = reservedSlots
|
|
var itemUpdate currentItemListEntry
|
|
result, err := owner.GrantPickupItem(ctx, dnfdungeon.PickupItemCommand{
|
|
CharacterID: characterID,
|
|
Placement: placement,
|
|
UpdatedAt: now,
|
|
Finalize: func(slot int16, stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) {
|
|
if stack.ItemID != int64(definition.ItemID) || stack.Count <= 0 {
|
|
return dnfrepo.ItemStack{}, errDungeonPickupItemInvalid
|
|
}
|
|
if finalize != nil {
|
|
stack, err = finalize(slot, stack)
|
|
if err != nil {
|
|
return dnfrepo.ItemStack{}, err
|
|
}
|
|
}
|
|
itemUpdate = currentItemListEntryFromStack(
|
|
placementListType,
|
|
slot,
|
|
stack,
|
|
)
|
|
stack.RawEntry = append([]byte(nil), itemUpdate.data[:]...)
|
|
return stack, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, mapCurrentDungeonPickupOwnerError(err)
|
|
}
|
|
return result.Slot, itemUpdate, nil
|
|
}
|
|
|
|
// currentDungeonPickupAccountSharedSlot 给出这次掉落应当落进的账号共享固定槽。
|
|
//
|
|
// 过去这里叫 currentDungeonPickupSharedCrystalSlot,并且用 CrystalWarehouseLastSlot(359)
|
|
// 收尾 —— 只认六个晶块(354..359),把六个灵魂(360..365)整族挡在门外,于是灵魂掉落会走
|
|
// grantCurrentDungeonPickupItem 落进**角色背包**。这与 NPC 商店金币购买那次是同一类
|
|
// "只打了一半"的缺陷(2026-09-21 一并修掉)。
|
|
//
|
|
// 底层 dnfdungeon.Owner.GrantAccountSharedPickupItem 本来就用全区间
|
|
// dnfrepo.IsAccountSharedInventorySlot(354..365) 校验,缺的只是 bridge 侧这个谓词。
|
|
// 现在直接查权威表(repository/account_shared_materials.go),不再各自维护范围判断。
|
|
func currentDungeonPickupAccountSharedSlot(itemID uint32) (int16, bool) {
|
|
slot, ok := dnfrepo.AccountSharedWarehouseSlotForItem(int64(itemID))
|
|
if !ok || !dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, slot) {
|
|
return 0, false
|
|
}
|
|
return slot, true
|
|
}
|
|
|
|
// grantCurrentDungeonPickupAccountShared 把整包掉落并进它在账号共享仓库里的固定格。
|
|
// 名字里的 Crystal 已去掉:六个晶块(354..359)与六个灵魂(360..365)走的是同一条路。
|
|
func grantCurrentDungeonPickupAccountShared(
|
|
ctx context.Context,
|
|
uow dnfrepo.AccountCharacterAssetUnitOfWork,
|
|
accountID string,
|
|
characterID string,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
sharedSlot int16,
|
|
now time.Time,
|
|
qualitySeed ...uint32,
|
|
) (uint16, currentItemListEntry, error) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
characterID = strings.TrimSpace(characterID)
|
|
mappedSlot, mapped := currentDungeonPickupAccountSharedSlot(definition.ItemID)
|
|
if uow == nil || accountID == "" || characterID == "" || amount == 0 || !mapped || mappedSlot != sharedSlot {
|
|
return 0, currentItemListEntry{}, errDungeonPickupTransactionMissing
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
var err error
|
|
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, err
|
|
}
|
|
if definition.Kind != dungeonDropItemStackable {
|
|
return 0, currentItemListEntry{}, errDungeonPickupItemInvalid
|
|
}
|
|
owner, err := dnfdungeon.NewOwner(dnfrepo.Group{AccountAssets: uow})
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, errDungeonPickupTransactionMissing
|
|
}
|
|
var itemUpdate currentItemListEntry
|
|
result, err := owner.GrantAccountSharedPickupItem(ctx, dnfdungeon.AccountSharedPickupItemCommand{
|
|
AccountID: accountID,
|
|
CharacterID: characterID,
|
|
SharedSlot: sharedSlot,
|
|
Placement: currentDungeonPickupPlacement(definition, amount, qualitySeed...),
|
|
UpdatedAt: now,
|
|
Finalize: func(slot int16, stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) {
|
|
if stack.ItemID != int64(definition.ItemID) || stack.Count <= 0 {
|
|
return dnfrepo.ItemStack{}, errDungeonPickupItemInvalid
|
|
}
|
|
itemUpdate = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), itemUpdate.data[:]...)
|
|
return stack, nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
return 0, currentItemListEntry{}, mapCurrentDungeonPickupOwnerError(err)
|
|
}
|
|
return result.Slot, itemUpdate, nil
|
|
}
|
|
|
|
func addCurrentDungeonPickupToInventory(
|
|
record *dnfrepo.InventoryRecord,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
qualitySeed ...uint32,
|
|
) (uint16, error) {
|
|
result, _, err := dnfdungeon.PlacePickupItem(
|
|
record,
|
|
currentDungeonPickupPlacement(definition, amount, qualitySeed...),
|
|
)
|
|
if err != nil {
|
|
return 0, mapCurrentDungeonPickupOwnerError(err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// currentDungeonPickupListPlacement reports the list and page a dropped item is
|
|
// picked up into. Guild medals ([flag]) and guardian gems ([flag gem]) live on
|
|
// list 38: the client can only wear a medal or embed a gem from that list, so a
|
|
// pickup written to the PVF's list-0 page sat in the bag unusable until the
|
|
// login migration (migrateLegacyGuildMedalItemsFromMainInventory) moved it -
|
|
// which is exactly the "捡到时不对, 重登才正常" report. Everything else keeps the
|
|
// ordinary list-0 page. Same rule as the NPC guild exchange
|
|
// (currentGuildMedalListPlacement), so both grant paths agree.
|
|
func currentDungeonPickupListPlacement(definition dungeonDropItemDefinition) (byte, int16, int16) {
|
|
if start, end, ok := currentGuildMedalListPlacement(definition); ok {
|
|
return currentGuildMedalInventoryListType, start, end
|
|
}
|
|
return dnfrepo.MainInventoryListType, definition.SlotStart, definition.SlotEnd
|
|
}
|
|
|
|
func currentDungeonPickupPlacement(
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
qualitySeed ...uint32,
|
|
) dnfdungeon.PickupItemPlacement {
|
|
kind := dnfdungeon.PickupItemKind(definition.Kind)
|
|
listType, slotStart, slotEnd := currentDungeonPickupListPlacement(definition)
|
|
placement := dnfdungeon.PickupItemPlacement{
|
|
ListType: listType,
|
|
Definition: dnfdungeon.PickupItemDefinition{
|
|
ItemID: definition.ItemID,
|
|
Kind: kind,
|
|
StackLimit: definition.StackLimit,
|
|
SlotStart: slotStart,
|
|
SlotEnd: slotEnd,
|
|
// The quick-slot preference reaches for list-0 slots 3..8, which do
|
|
// not exist on list 38. Keep it to the main bag.
|
|
PreferQuickSlot: listType == dnfrepo.MainInventoryListType &&
|
|
definition.Kind == dungeonDropItemStackable &&
|
|
dungeonDropStackablePrefersItemQuickSlots(definition.StackableType),
|
|
},
|
|
Amount: amount,
|
|
}
|
|
if !definition.ExpirationDate.IsZero() {
|
|
placement.NormalizeExisting = func(stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) {
|
|
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
|
|
return stack, nil
|
|
}
|
|
}
|
|
placement.BuildNew = func(slot int16) (dnfrepo.ItemStack, error) {
|
|
return buildCurrentDungeonPickupStack(slot, definition, amount, qualitySeed...)
|
|
}
|
|
return placement
|
|
}
|
|
|
|
func buildCurrentDungeonPickupStack(
|
|
slot int16,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
qualitySeed ...uint32,
|
|
) (dnfrepo.ItemStack, error) {
|
|
entry, err := currentDungeonDeathDropItemState(uint16(slot), definition, amount, qualitySeed...)
|
|
if err != nil {
|
|
return dnfrepo.ItemStack{}, err
|
|
}
|
|
extra := map[string]string{
|
|
"source": "dungeon_pvf_drop_pickup",
|
|
"item_kind": string(definition.Kind),
|
|
"pvf_path": definition.PVFPath,
|
|
}
|
|
if definition.StackableType != "" {
|
|
extra["stackable_type"] = definition.StackableType
|
|
}
|
|
if definition.StackLimit > 0 {
|
|
extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
|
|
}
|
|
if definition.Durability > 0 {
|
|
extra["durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
|
|
extra["max_durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
|
|
}
|
|
if definition.EquipmentType != "" {
|
|
extra["equipment_type"] = definition.EquipmentType
|
|
}
|
|
if currentDungeonDroppedEquipmentShouldBeSealed(definition) {
|
|
extra["seal_flag"] = "1"
|
|
}
|
|
stack := dnfrepo.ItemStack{
|
|
ItemID: int64(definition.ItemID),
|
|
Count: int64(amount),
|
|
Extra: extra,
|
|
}
|
|
if definition.Kind == dungeonDropItemEquipment {
|
|
if err := applyCurrentEquipmentQualitySeed(&stack, binary.LittleEndian.Uint32(entry.data[6:10])); err != nil {
|
|
return dnfrepo.ItemStack{}, err
|
|
}
|
|
}
|
|
if !definition.ExpirationDate.IsZero() {
|
|
stack, _ = applyCurrentPVFItemExpiration(stack, definition)
|
|
}
|
|
entry = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
|
return stack, nil
|
|
}
|
|
|
|
func insertCurrentDungeonPickup(
|
|
record *dnfrepo.InventoryRecord,
|
|
definition dungeonDropItemDefinition,
|
|
amount uint32,
|
|
slot int16,
|
|
qualitySeed ...uint32,
|
|
) (uint16, error) {
|
|
if record == nil {
|
|
return 0, errDungeonPickupItemInvalid
|
|
}
|
|
stack, err := buildCurrentDungeonPickupStack(slot, definition, amount, qualitySeed...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if record.Slots == nil {
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
record.Slots[currentDungeonPickupMainSlotKey(slot)] = stack
|
|
return uint16(slot), nil
|
|
}
|
|
|
|
func mapCurrentDungeonPickupOwnerError(err error) error {
|
|
switch {
|
|
case errors.Is(err, dnfdungeon.ErrOwnerUnavailable):
|
|
return errDungeonPickupTransactionMissing
|
|
case errors.Is(err, dnfdungeon.ErrInventoryNotFound):
|
|
return errDungeonPickupInventoryMissing
|
|
case errors.Is(err, dnfdungeon.ErrPickupInventoryFull):
|
|
return fmt.Errorf("%w: %v", errDungeonPickupInventoryFull, err)
|
|
case errors.Is(err, dnfdungeon.ErrPickupStackLimit):
|
|
return fmt.Errorf("%w: %v", errDungeonPickupStackLimit, err)
|
|
case errors.Is(err, dnfdungeon.ErrPickupItemInvalid):
|
|
return fmt.Errorf("%w: %v", errDungeonPickupItemInvalid, err)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func currentDungeonPickupMainSlotKey(slot int16) string {
|
|
return "0:" + strconv.FormatInt(int64(slot), 10)
|
|
}
|