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

1038 lines
38 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"math"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestBuildCurrentDungeonNormalPickupResultMatchesCurrentEXEOp39ItemBranch(t *testing.T) {
body, err := buildCurrentDungeonNormalPickupResultBody(0x44332211, 401, 121)
if err != nil {
t.Fatal(err)
}
if len(body) != currentDungeonPickupResultSize {
t.Fatalf("body len=%d", len(body))
}
if binary.LittleEndian.Uint32(body[0:4]) != 0x44332211 ||
binary.LittleEndian.Uint16(body[4:6]) != 401 ||
!bytes.Equal(body[6:14], make([]byte, 8)) ||
binary.LittleEndian.Uint16(body[14:16]) != 401 ||
binary.LittleEndian.Uint16(body[16:18]) != 121 ||
body[18] != 0 {
t.Fatalf("body=%x", body)
}
for _, test := range []struct {
drop uint32
picker uint16
slot uint16
}{{picker: 1, slot: 1}, {drop: 1, slot: 1}, {drop: 1, picker: 1}} {
if _, err := buildCurrentDungeonNormalPickupResultBody(test.drop, test.picker, test.slot); !errors.Is(err, errDungeonPickupResponseInvalid) {
t.Fatalf("invalid response values accepted: %+v err=%v", test, err)
}
}
}
func TestBuildCurrentDungeonGoldPickupEffectMatchesCurrentEXEOp557(t *testing.T) {
body, err := buildCurrentDungeonGoldPickupEffectBody(979)
if err != nil {
t.Fatal(err)
}
if len(body) != currentDungeonGoldPickupEffectSize || binary.LittleEndian.Uint16(body) != 979 {
t.Fatalf("body=%x", body)
}
for _, amount := range []uint32{0, math.MaxUint16 + 1} {
if _, err := buildCurrentDungeonGoldPickupEffectBody(amount); !errors.Is(err, errDungeonPickupResponseInvalid) {
t.Fatalf("invalid amount=%d accepted err=%v", amount, err)
}
}
}
func TestAddCurrentDungeonPickupKeepsMaterialsInPVFCategorySlots(t *testing.T) {
material := dungeonDropItemDefinition{
ItemID: 3227,
Kind: dungeonDropItemStackable,
PVFPath: "stackable/material/test_drop.stk",
StackableType: "[material]",
StackLimit: 999,
SlotStart: 121,
SlotEnd: 176,
}
t.Run("existing quick-slot stack is ignored in favor of material category", func(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:4": {ItemID: 3227, Count: 2},
"0:121": {ItemID: 3227, Count: 5},
}}
slot, err := addCurrentDungeonPickupToInventory(&record, material, 1)
if err != nil {
t.Fatal(err)
}
if slot != 121 || record.Slots["0:4"].Count != 2 || record.Slots["0:121"].Count != 6 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
})
t.Run("same material-category stack is merged", func(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 3227, Count: 5},
}}
slot, err := addCurrentDungeonPickupToInventory(&record, material, 1)
if err != nil {
t.Fatal(err)
}
if slot != 121 || record.Slots["0:121"].Count != 6 || len(record.Slots) != 1 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
})
t.Run("material subtype four uses independent material page", func(t *testing.T) {
materialFour := material
materialFour.StackableType = "[material] 4"
materialFour.SlotStart = 345
materialFour.SlotEnd = 359
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
slot, err := addCurrentDungeonPickupToInventory(&record, materialFour, 1)
if err != nil {
t.Fatal(err)
}
if slot != 345 || record.Slots["0:345"].ItemID != 3227 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
if _, exists := record.Slots["0:3"]; exists {
t.Fatalf("material subtype four entered quick slot: %+v", record.Slots)
}
})
t.Run("waste still prefers item quick slots", func(t *testing.T) {
waste := material
waste.StackableType = "[waste]"
waste.SlotStart = 65
waste.SlotEnd = 120
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
slot, err := addCurrentDungeonPickupToInventory(&record, waste, 1)
if err != nil {
t.Fatal(err)
}
if slot != 3 || record.Slots["0:3"].ItemID != 3227 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
})
t.Run("full quick slots fall back to original category stack", func(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 3227, Count: 5},
}}
for slot := int16(3); slot <= 8; slot++ {
record.Slots[currentDungeonPickupMainSlotKey(slot)] = dnfrepo.ItemStack{
ItemID: 900000 + int64(slot),
Count: 1,
}
}
slot, err := addCurrentDungeonPickupToInventory(&record, material, 1)
if err != nil {
t.Fatal(err)
}
if slot != 121 || record.Slots["0:121"].Count != 6 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
})
t.Run("PVF stack limit zero merges as unlimited", func(t *testing.T) {
unlimited := material
unlimited.ItemID = 3050
unlimited.StackLimit = 0
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:136": {ItemID: 3050, Count: 1},
}}
for slot := int16(3); slot <= 8; slot++ {
record.Slots[currentDungeonPickupMainSlotKey(slot)] = dnfrepo.ItemStack{
ItemID: 900000 + int64(slot),
Count: 1,
}
}
slot, err := addCurrentDungeonPickupToInventory(&record, unlimited, 1)
if err != nil {
t.Fatal(err)
}
if slot != 136 || record.Slots["0:136"].Count != 2 || len(record.Slots) != 7 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
})
t.Run("quest type keeps original category", func(t *testing.T) {
quest := material
quest.StackableType = "[quest]"
quest.SlotStart = 177
quest.SlotEnd = 232
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
slot, err := addCurrentDungeonPickupToInventory(&record, quest, 1)
if err != nil {
t.Fatal(err)
}
if slot != 177 || record.Slots["0:177"].ItemID != 3227 {
t.Fatalf("slot=%d inventory=%+v", slot, record.Slots)
}
if _, exists := record.Slots["0:3"]; exists {
t.Fatalf("quest item entered quick slot: %+v", record.Slots)
}
})
}
func TestAddCurrentDungeonPickupPersistsPVFStackableUsePeriodInRaw77(t *testing.T) {
expirationDate := time.Date(2028, time.August, 15, 22, 0, 0, 0, time.UTC)
definition := dungeonDropItemDefinition{
ItemID: 490702319,
Kind: dungeonDropItemStackable,
PVFPath: "stackable/cash/chn_490702319.stk",
StackableType: "[booster]",
StackLimit: 1000,
SlotStart: 65,
SlotEnd: 120,
ExpirationDate: expirationDate,
}
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
slot, err := addCurrentDungeonPickupToInventory(&record, definition, 20)
if err != nil {
t.Fatal(err)
}
stack := record.Slots[currentDungeonPickupMainSlotKey(int16(slot))]
if !stack.ExpireAt.Equal(expirationDate) || stack.Extra["expire_time"] != "1849989600" || stack.Extra["expire_unix"] != "1849989600" ||
stack.Extra["item_kind"] != string(dungeonDropItemStackable) ||
len(stack.RawEntry) != currentItemListEntryWireSize || binary.LittleEndian.Uint16(stack.RawEntry[0x0B:0x0D]) != 0xFFFF ||
binary.LittleEndian.Uint32(stack.RawEntry[0x6E:0x72]) != 0 {
t.Fatalf("inserted expiring stack=%+v row=%x", stack, stack.RawEntry)
}
stale := stack
stale.Extra = nil
stale.RawEntry = append([]byte(nil), stale.RawEntry...)
binary.LittleEndian.PutUint16(stale.RawEntry[0x0B:0x0D], 0)
record.Slots[currentDungeonPickupMainSlotKey(int16(slot))] = stale
mergedSlot, err := addCurrentDungeonPickupToInventory(&record, definition, 5)
if err != nil {
t.Fatal(err)
}
merged := record.Slots[currentDungeonPickupMainSlotKey(int16(mergedSlot))]
if mergedSlot != slot || merged.Count != 25 || !merged.ExpireAt.Equal(expirationDate) || merged.Extra["expire_time"] != "1849989600" ||
binary.LittleEndian.Uint16(merged.RawEntry[0x0B:0x0D]) != 0xFFFF || binary.LittleEndian.Uint32(merged.RawEntry[0x6E:0x72]) != 0 {
t.Fatalf("merged expiring stack slot=%d stack=%+v", mergedSlot, merged)
}
}
func TestCurrentDungeonNormalMonsterDropRequiresRuntimePVFRules(t *testing.T) {
service, runtime, _ := prepareCurrentDungeonDropTest(t, 2, 3227)
monster := runtime.Room.Snapshot().Monsters[0]
if _, err := service.planCurrentDungeonMonsterDrops(runtime, monster, currentSceneActorObjectKey(99)); !errors.Is(err, errCurrentDungeonDropRulesUnavailable) {
t.Fatalf("normal-drop planner error=%v", err)
}
if runtime.NextObjectKey != 403 || runtime.DropOwner != nil {
t.Fatalf("deferred planner mutated runtime next=%d owner=%+v", runtime.NextObjectKey, runtime.DropOwner)
}
}
func TestCurrentDungeonGoldDropRegistersRealAmountForPickup(t *testing.T) {
service, runtime, _ := prepareCurrentDungeonDropTest(t, 0, 3227)
source, ok := service.dungeonMonsterTable.source.(bridgePVFSource)
if !ok {
t.Fatalf("test source type=%T", service.dungeonMonsterTable.source)
}
source[dungeonCardDropRulePath] = currentDungeonGoldDropTestRuleText()
source[dungeonCardGoldReferencePath] = "[gold drop ref table]\n20 100 0\n"
currentDungeonMonsterDropRulesByCatalog.Delete(service.dungeonMonsterTable)
currentDungeonGoldReferencesByCatalog.Delete(service.dungeonMonsterTable)
monster := runtime.Room.Snapshot().Monsters[0]
wires, err := service.planCurrentDungeonMonsterDrops(runtime, monster, currentSceneActorObjectKey(99))
if err != nil {
t.Fatal(err)
}
var goldWire *currentDungeonDeathDropWire
for index := range wires {
if binary.LittleEndian.Uint32(wires[index].Item.data[0x02:0x06]) == 0 {
goldWire = &wires[index]
break
}
}
if goldWire == nil {
t.Fatalf("gold drop was not planned: wires=%+v", wires)
}
wireAmount := binary.LittleEndian.Uint32(goldWire.Item.data[0x06:0x0A])
if wireAmount != 120 {
t.Fatalf("gold wire amount=%d want 120", wireAmount)
}
registered := runtime.DropOwner.byObjectKey[goldWire.SceneObjectKey]
if registered == nil || !registered.isGold() || registered.Amount != wireAmount {
t.Fatalf("registered gold drop=%+v wire_amount=%d", registered, wireAmount)
}
}
func TestCurrentDungeonDropPlannerSkipsPersistedDropObjectKeys(t *testing.T) {
service, runtime, _ := prepareCurrentDungeonDropTest(t, 0, 3227)
source, ok := service.dungeonMonsterTable.source.(bridgePVFSource)
if !ok {
t.Fatalf("test source type=%T", service.dungeonMonsterTable.source)
}
source[dungeonCardDropRulePath] = currentDungeonGoldDropTestRuleText()
source[dungeonCardGoldReferencePath] = "[gold drop ref table]\n20 100 0\n"
currentDungeonMonsterDropRulesByCatalog.Delete(service.dungeonMonsterTable)
currentDungeonGoldReferencesByCatalog.Delete(service.dungeonMonsterTable)
staleNextObjectKey := runtime.NextObjectKey
owner := newRuntimeDungeonDropOwner()
if err := owner.registerBatch([]*runtimeDungeonDrop{{
ObjectKey: staleNextObjectKey,
SceneSlot: 1,
OwnerActorObjectKey: currentSceneActorObjectKey(99),
Room: runtimeDungeonRoomKey{X: -1, Y: -1, MapID: -1},
Status: runtimeDungeonDropConsumed,
}}, 2); err != nil {
t.Fatal(err)
}
runtime.DropOwner = owner
monster := runtime.Room.Snapshot().Monsters[0]
wires, err := service.planCurrentDungeonMonsterDrops(runtime, monster, currentSceneActorObjectKey(99))
if err != nil {
t.Fatal(err)
}
if len(wires) == 0 || wires[0].SceneObjectKey <= staleNextObjectKey {
t.Fatalf("drop planner reused persisted object key: wires=%+v prior=%d", wires, staleNextObjectKey)
}
}
func TestCurrentDungeonGoldPickupSendsRemoveEffectThenAbsoluteState(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 0, 3227)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "dungeon-gold-pickup-effect-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
scene, ok := runtime.Session.Scene()
if !ok {
t.Fatal("missing dungeon scene")
}
objectKey := runtime.NextObjectKey
drop := &runtimeDungeonDrop{
ObjectKey: objectKey,
SceneSlot: 1,
Amount: 979,
OwnerActorObjectKey: currentSceneActorObjectKey(99),
Room: runtimeDungeonRoomKeyFromScene(scene),
Status: runtimeDungeonDropAvailable,
}
owner := newRuntimeDungeonDropOwner()
if err := owner.registerBatch([]*runtimeDungeonDrop{drop}, 2); err != nil {
t.Fatal(err)
}
runtime.DropOwner = owner
runtime.NextObjectKey++
pickupBody := currentDungeonPickupTestBody(objectKey)
if err := service.handleCurrentDungeonPickup(session, pickupBody); err != nil {
t.Fatal(err)
}
removePacket, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
effectPacket, rest := splitGameServerUpperPacket(t, rest)
goldStatePacket, trailing := splitGameServerUpperPacket(t, rest)
wantRemove, err := buildCurrentDungeonGoldPickupResultBody(objectKey, currentSceneActorObjectKey(99))
if err != nil {
t.Fatal(err)
}
if removePacket.Header.Classification != 0 || removePacket.Header.MsgID != currentDungeonPickupResultOpcode ||
!bytes.Equal(removePacket.Body, wantRemove) {
t.Fatalf("remove header=%+v body=%x", removePacket.Header, removePacket.Body)
}
if effectPacket.Header.Classification != 0 || effectPacket.Header.MsgID != currentDungeonGoldPickupEffectOpcode ||
len(effectPacket.Body) != currentDungeonGoldPickupEffectSize || binary.LittleEndian.Uint16(effectPacket.Body) != 979 {
t.Fatalf("effect header=%+v body=%x", effectPacket.Header, effectPacket.Body)
}
if goldStatePacket.Header.Classification != 0 || goldStatePacket.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
!bytes.Equal(goldStatePacket.Body, buildCurrentGoldStateBody(979)) || len(trailing) != 0 {
t.Fatalf("gold state header=%+v body=%x trailing=%x", goldStatePacket.Header, goldStatePacket.Body, trailing)
}
character, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found || character.Stats["gold"] != 979 {
t.Fatalf("character found=%t err=%v stats=%+v", found, err, character.Stats)
}
if drop.Status != runtimeDungeonDropConsumed || drop.GoldAfter != 979 {
t.Fatalf("consumed gold drop=%+v", drop)
}
connection.write.Reset()
if err := service.handleCurrentDungeonPickup(session, pickupBody); err != nil {
t.Fatal(err)
}
replayRemove, replayRest := splitGameServerUpperPacket(t, connection.write.Bytes())
replayGoldState, replayTrailing := splitGameServerUpperPacket(t, replayRest)
if replayRemove.Header.MsgID != currentDungeonPickupResultOpcode ||
replayGoldState.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(replayTrailing) != 0 {
t.Fatalf("replay remove=%+v state=%+v trailing=%x", replayRemove.Header, replayGoldState.Header, replayTrailing)
}
character, _, err = repositories.Character.Load(context.Background(), "99")
if err != nil || character.Stats["gold"] != 979 {
t.Fatalf("replay changed gold err=%v stats=%+v", err, character.Stats)
}
}
func TestCurrentDungeonNormalPickupIsOwnedTransactionalAndIdempotent(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 2, 3227)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "dungeon-drop-pickup-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
monster := runtime.Room.Snapshot().Monsters[0]
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], monster.ObjectKey)
binary.LittleEndian.PutUint16(deathBody[4:6], currentSceneActorObjectKey(99))
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
deathPacket, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if deathPacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
deathPacket.Header.Classification != 0 {
t.Fatalf("death packet header=%+v rest=%x", deathPacket.Header, rest)
}
assertCurrentDungeonFinalClearTail(t, rest)
if len(deathPacket.Body) != currentDungeonZeroDropDeathBodySize || deathPacket.Body[2] != 0 {
t.Fatalf("death body len=%d count=%d body=%x", len(deathPacket.Body), deathPacket.Body[2], deathPacket.Body)
}
firstDropObjectKey := seedCurrentDungeonPickupDrop(t, service, runtime, 3227, currentSceneActorObjectKey(99))
connection.write.Reset()
pickupBody := currentDungeonPickupTestBody(firstDropObjectKey)
pickupFrame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.CmdPacketGetItem),
pickupBody,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, pickupFrame); err != nil {
t.Fatal(err)
}
pickupPacket, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
itemUpdate, trailing := splitGameServerUpperPacket(t, rest)
wantPickup, err := buildCurrentDungeonNormalPickupResultBody(firstDropObjectKey, currentSceneActorObjectKey(99), 121)
if err != nil {
t.Fatal(err)
}
if pickupPacket.Header.MsgID != currentDungeonPickupResultOpcode ||
pickupPacket.Header.Classification != 0 ||
!bytes.Equal(pickupPacket.Body, wantPickup) || len(trailing) != 0 {
t.Fatalf("pickup packet header=%+v body=%x trailing=%x", pickupPacket.Header, pickupPacket.Body, trailing)
}
if itemUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
itemUpdate.Header.Classification != 0 ||
len(itemUpdate.Body) != 3+currentItemListEntryWireSize ||
itemUpdate.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(itemUpdate.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(itemUpdate.Body[3:5]) != 121 ||
binary.LittleEndian.Uint32(itemUpdate.Body[5:9]) != 3227 ||
binary.LittleEndian.Uint32(itemUpdate.Body[9:13]) != 1 {
t.Fatalf("pickup item update header=%+v body=%x", itemUpdate.Header, itemUpdate.Body)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
stack := inventory.Slots["0:121"]
if stack.ItemID != 3227 || stack.Count != 1 || stack.Extra["pvf_path"] != "stackable/material/test_drop.stk" ||
stack.Extra["stack_limit"] != "999" || len(stack.RawEntry) != currentItemListEntryWireSize {
t.Fatalf("persisted stack=%+v", stack)
}
reloadBody, reloadSource, reloadCount, reloadOK := service.buildCurrentItemListBodyForSession(
context.Background(),
session,
dnfrepo.MainInventoryListType,
)
if !reloadOK || reloadSource != "inventory" || reloadCount != 1 ||
len(reloadBody) != 5+currentItemListEntryWireSize ||
reloadBody[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(reloadBody[3:5]) != 1 {
t.Fatalf(
"persisted pickup reload ok=%t source=%q count=%d body=%x",
reloadOK,
reloadSource,
reloadCount,
reloadBody,
)
}
reloadedEntry := reloadBody[5:]
if binary.LittleEndian.Uint16(reloadedEntry[0:2]) != 121 ||
binary.LittleEndian.Uint32(reloadedEntry[2:6]) != 3227 ||
binary.LittleEndian.Uint32(reloadedEntry[6:10]) != 1 {
t.Fatalf("persisted pickup reloaded entry=%x", reloadedEntry)
}
drop := runtime.DropOwner.byObjectKey[firstDropObjectKey]
if drop.Status != runtimeDungeonDropConsumed || drop.DestinationSlot != 121 || len(drop.PickupResponseBody) != currentDungeonPickupResultSize {
t.Fatalf("consumed drop=%+v", drop)
}
connection.write.Reset()
if err := service.handleCurrentDungeonPickup(session, pickupBody); err != nil {
t.Fatal(err)
}
replayPacket, replayRest := splitGameServerUpperPacket(t, connection.write.Bytes())
replayUpdate, replayTrailing := splitGameServerUpperPacket(t, replayRest)
if !bytes.Equal(replayPacket.Body, wantPickup) || len(replayTrailing) != 0 ||
!bytes.Equal(replayUpdate.Body, itemUpdate.Body) {
t.Fatalf("replay body=%x update=%x trailing=%x", replayPacket.Body, replayUpdate.Body, replayTrailing)
}
inventory, _, err = repositories.Inventory.Load(context.Background(), "99")
if err != nil || inventory.Slots["0:121"].Count != 1 {
t.Fatalf("duplicate pickup changed inventory=%+v err=%v", inventory.Slots, err)
}
}
func TestCurrentDungeonPickupRoutesCubeFragmentsToSharedCrystalSlots(t *testing.T) {
for _, test := range []struct {
itemID uint32
slot int16
}{
{3033, 354},
{3034, 355},
{3035, 356},
{3036, 357},
{3037, 358},
{3262, 359},
} {
t.Run(strconv.FormatUint(uint64(test.itemID), 10), func(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 1, test.itemID)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "dungeon-cube-pickup-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
dropObjectKey := seedCurrentDungeonPickupDrop(t, service, runtime, test.itemID, currentSceneActorObjectKey(99))
if err := service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(dropObjectKey)); err != nil {
t.Fatal(err)
}
pickup, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
update, trailing := splitGameServerUpperPacket(t, rest)
if pickup.Header.MsgID != currentDungeonPickupResultOpcode || len(trailing) != 0 ||
update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || update.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(update.Body[1:3]) != 1 || binary.LittleEndian.Uint16(update.Body[3:5]) != uint16(test.slot) ||
binary.LittleEndian.Uint32(update.Body[5:9]) != test.itemID || binary.LittleEndian.Uint32(update.Body[9:13]) != 1 {
t.Fatalf("cube pickup=%+v update=%x trailing=%x", pickup.Header, update.Body, trailing)
}
account, found, err := repositories.AccountInventory.Load(context.Background(), "account-1")
if err != nil || !found {
t.Fatalf("account inventory found=%t err=%v", found, err)
}
if stack := account.Slots[dnfrepo.AccountSharedInventorySlotKey(test.slot)]; stack.ItemID != int64(test.itemID) || stack.Count != 1 {
t.Fatalf("shared crystal stack=%+v", stack)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("character inventory found=%t slots=%+v err=%v", found, inventory.Slots, err)
}
})
}
}
func TestCurrentDungeonPickupConsumesOnlyRequestedGroundObject(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 1, 3227)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "dungeon-manual-pickup-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
firstObjectKey := seedCurrentDungeonPickupDrop(
t,
service,
runtime,
3227,
currentSceneActorObjectKey(99),
)
first := runtime.DropOwner.byObjectKey[firstObjectKey]
second := *first
second.ObjectKey = runtime.NextObjectKey
second.SceneSlot = first.SceneSlot + 1
third := *first
third.ObjectKey = runtime.NextObjectKey + 1
third.SceneSlot = first.SceneSlot + 2
if err := runtime.DropOwner.registerBatch([]*runtimeDungeonDrop{&second, &third}, third.SceneSlot+1); err != nil {
t.Fatal(err)
}
runtime.NextObjectKey += 2
if err := service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(second.ObjectKey)); err != nil {
t.Fatal(err)
}
if runtime.DropOwner.byObjectKey[second.ObjectKey].Status != runtimeDungeonDropConsumed {
t.Fatalf("selected drop status=%d, want consumed", runtime.DropOwner.byObjectKey[second.ObjectKey].Status)
}
for _, objectKey := range []uint32{firstObjectKey, third.ObjectKey} {
if status := runtime.DropOwner.byObjectKey[objectKey].Status; status != runtimeDungeonDropAvailable {
t.Fatalf("unselected drop %d status=%d, want available", objectKey, status)
}
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 3227 || stack.Count != 1 {
t.Fatalf("manual pickup stack=%+v", stack)
}
pickupPacket, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if pickupPacket.Header.MsgID != currentDungeonPickupResultOpcode ||
binary.LittleEndian.Uint32(pickupPacket.Body[0:4]) != second.ObjectKey {
t.Fatalf("pickup header=%+v body=%x", pickupPacket.Header, pickupPacket.Body)
}
itemUpdate, trailing := splitGameServerUpperPacket(t, rest)
if itemUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || len(trailing) != 0 {
t.Fatalf("item update header=%+v trailing=%x", itemUpdate.Header, trailing)
}
}
func TestCurrentDungeonPickupTransactionFailureLeavesDropAvailableAndSendsNothing(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 1, 3227)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "dungeon-drop-rollback-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
dropObjectKey := seedCurrentDungeonPickupDrop(
t,
service,
runtime,
3227,
currentSceneActorObjectKey(99),
)
wantErr := errors.New("forced pickup save failure")
failingInventory := pickupFailingInventoryRepository{InventoryRepository: repositories.Inventory, err: wantErr}
failingGroup := repositories
failingGroup.CharacterItems = pickupTestCharacterItemUnitOfWork{
base: repositories.CharacterItems,
inventory: failingInventory,
equipment: repositories.Equipment,
}
service.repositoryProvider = func() (dnfrepo.Group, bool) { return failingGroup, true }
connection.write.Reset()
if err := service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(dropObjectKey)); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("failed transaction emitted packet=%x", connection.write.Bytes())
}
if drop := runtime.DropOwner.byObjectKey[dropObjectKey]; drop.Status != runtimeDungeonDropAvailable || len(drop.PickupResponseBody) != 0 {
t.Fatalf("failed transaction consumed drop=%+v", drop)
}
inventory, _, err := repositories.Inventory.Load(context.Background(), "99")
if err != nil || len(inventory.Slots) != 0 {
t.Fatalf("failed transaction changed inventory=%+v err=%v", inventory.Slots, err)
}
}
func TestCurrentDungeonPickupRepositoryWriteDoesNotBlockSessionEvents(t *testing.T) {
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 1, 3227)
connection := &bufferConn{}
ctx, cancel := context.WithCancel(context.Background())
session := &gameSession{
conn: connection,
connID: "dungeon-pickup-event-loop-test",
selectedCharacterID: 99,
characterGeneration: 1,
ctx: ctx,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.startGameSessionEvents(ctx, session); err != nil {
cancel()
t.Fatal(err)
}
releaseWrite := make(chan struct{})
var releaseWriteOnce sync.Once
releaseRepositoryWrite := func() {
releaseWriteOnce.Do(func() { close(releaseWrite) })
}
defer func() {
releaseRepositoryWrite()
cancel()
stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
_ = service.stopGameSessionEvents(stopCtx, session)
}()
writeEntered := make(chan struct{})
writeDeadline := make(chan time.Duration, 1)
blockingInventory := &pickupBlockingInventoryRepository{
InventoryRepository: repositories.Inventory,
entered: writeEntered,
release: releaseWrite,
deadline: writeDeadline,
}
blockingGroup := repositories
blockingGroup.CharacterItems = pickupTestCharacterItemUnitOfWork{
base: repositories.CharacterItems,
inventory: blockingInventory,
equipment: repositories.Equipment,
}
service.repositoryProvider = func() (dnfrepo.Group, bool) { return blockingGroup, true }
dropObjectKey := seedCurrentDungeonPickupDrop(
t,
service,
runtime,
3227,
currentSceneActorObjectKey(99),
)
pickupDone := make(chan error, 1)
go func() {
pickupDone <- service.callGameSession(ctx, session, "blocked-pickup", func() error {
return service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(dropObjectKey))
})
}()
select {
case <-writeEntered:
case <-time.After(time.Second):
t.Fatal("pickup repository write did not start")
}
select {
case remaining := <-writeDeadline:
if remaining <= createWriteTimeout || remaining > currentDungeonPickupWriteTimeout {
t.Fatalf("pickup repository deadline remaining=%s want=(%s,%s]", remaining, createWriteTimeout, currentDungeonPickupWriteTimeout)
}
case <-time.After(time.Second):
t.Fatal("pickup repository write did not observe a context deadline")
}
if err := service.callGameSession(ctx, session, "pending-pickup-replay", func() error {
return service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(dropObjectKey))
}); err != nil {
t.Fatal(err)
}
session.dungeon.mu.Lock()
pendingDrop := runtime.DropOwner.byObjectKey[dropObjectKey]
pendingStatus := pendingDrop.Status
pendingAttempt := pendingDrop.PickupAttempt
session.dungeon.mu.Unlock()
if pendingStatus != runtimeDungeonDropPending || pendingAttempt != 1 {
t.Fatalf("pending replay changed drop status=%d attempt=%d", pendingStatus, pendingAttempt)
}
if connection.write.Len() != 0 {
t.Fatalf("pending replay emitted a premature packet=%x", connection.write.Bytes())
}
nextEventRan := make(chan struct{})
go func() {
_ = service.callGameSession(ctx, session, "next-dungeon-event", func() error {
close(nextEventRan)
return nil
})
}()
select {
case <-nextEventRan:
case <-time.After(250 * time.Millisecond):
t.Fatal("a blocked pickup repository write stalled the complete session event loop")
}
select {
case err := <-pickupDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(250 * time.Millisecond):
t.Fatal("pickup handler remained on the session event loop while persistence was pending")
}
releaseRepositoryWrite()
var wire []byte
completionDeadline := time.Now().Add(2 * time.Second)
for {
var status runtimeDungeonDropStatus
if err := service.callGameSession(ctx, session, "pickup-completion-barrier", func() error {
session.dungeon.mu.Lock()
status = runtime.DropOwner.byObjectKey[dropObjectKey].Status
session.dungeon.mu.Unlock()
if status == runtimeDungeonDropConsumed {
wire = append([]byte(nil), connection.write.Bytes()...)
}
return nil
}); err != nil {
t.Fatal(err)
}
if status == runtimeDungeonDropConsumed {
break
}
if time.Now().After(completionDeadline) {
t.Fatalf("pickup persistence did not complete; drop status=%d", status)
}
time.Sleep(time.Millisecond)
}
if calls := blockingInventory.calls.Load(); calls != 1 {
t.Fatalf("pending replay performed %d repository writes, want 1", calls)
}
pickupPacket, rest := splitGameServerUpperPacket(t, wire)
itemUpdate, trailing := splitGameServerUpperPacket(t, rest)
wantPickup, err := buildCurrentDungeonNormalPickupResultBody(
dropObjectKey,
currentSceneActorObjectKey(99),
121,
)
if err != nil {
t.Fatal(err)
}
if pickupPacket.Header.MsgID != currentDungeonPickupResultOpcode ||
!bytes.Equal(pickupPacket.Body, wantPickup) ||
itemUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(trailing) != 0 {
t.Fatalf(
"async pickup packet=%+v body=%x update=%+v trailing=%x",
pickupPacket.Header,
pickupPacket.Body,
itemUpdate.Header,
trailing,
)
}
}
func TestRuntimeDungeonDropOwnerRejectsStaleRoomAndActor(t *testing.T) {
owner := newRuntimeDungeonDropOwner()
drop := &runtimeDungeonDrop{
ObjectKey: 700,
SceneSlot: 1,
OwnerActorObjectKey: 401,
Room: runtimeDungeonRoomKey{X: 1, Y: 2, MapID: 3},
Status: runtimeDungeonDropAvailable,
}
if err := owner.registerBatch([]*runtimeDungeonDrop{drop}, 2); err != nil {
t.Fatal(err)
}
if _, err := owner.owned(700, runtimeDungeonRoomKey{X: 9, Y: 2, MapID: 3}, 401); !errors.Is(err, errDungeonDropRoomMismatch) {
t.Fatalf("stale-room error=%v", err)
}
if _, err := owner.owned(700, drop.Room, 402); !errors.Is(err, errDungeonDropActorMismatch) {
t.Fatalf("actor error=%v", err)
}
}
func seedCurrentDungeonPickupDrop(
t *testing.T,
service *Service,
runtime *runtimeDungeonState,
itemID uint32,
ownerActorObjectKey uint16,
) uint32 {
t.Helper()
catalog, err := service.dungeonMonsterCatalog()
if err != nil {
t.Fatal(err)
}
drops, err := catalog.DropCatalog()
if err != nil {
t.Fatal(err)
}
definition, err := drops.ResolveItem(itemID)
if err != nil {
t.Fatal(err)
}
scene, ok := runtime.Session.Scene()
if !ok {
t.Fatal("missing dungeon scene")
}
objectKey := runtime.NextObjectKey
if objectKey == 0 {
t.Fatal("missing next object key")
}
drop := &runtimeDungeonDrop{
ObjectKey: objectKey,
SceneSlot: 1,
Item: definition,
Amount: 1,
OwnerActorObjectKey: ownerActorObjectKey,
Room: runtimeDungeonRoomKeyFromScene(scene),
Status: runtimeDungeonDropAvailable,
}
owner := newRuntimeDungeonDropOwner()
if err := owner.registerBatch([]*runtimeDungeonDrop{drop}, 2); err != nil {
t.Fatal(err)
}
runtime.DropOwner = owner
runtime.NextObjectKey++
return objectKey
}
func prepareCurrentDungeonDropTest(
t *testing.T,
fixedDropCount int,
itemID uint32,
) (*Service, *runtimeDungeonState, dnfrepo.Group) {
t.Helper()
source := bridgeDungeonPVF(false)
source["map/dungeon/test/start.map"] = "[map name]\n`start`\n" +
"[dungeon]\n700\n" +
"[type]\n`[start]`\n" +
"[monster]\n3001 10 0 100 200 0 0 " + strconv.Itoa(fixedDropCount) + " `[fixed]` `[normal]`\n"
source["monster/test.gob"] += "[item]\n" + strconv.FormatUint(uint64(itemID), 10) + " 10\n[/item]\n"
source[dungeonDropStackableList] = strconv.FormatUint(uint64(itemID), 10) + " `material/test_drop.stk`\n"
source[dungeonDropEquipmentList] = "9001 `weapon/test_drop.equ`\n"
source["stackable/material/test_drop.stk"] = "[name]\n`Test Drop`\n[stackable type]\n`[material]`\n[stack limit]\n999\n[attach type]\n`[free]`\n"
source["equipment/weapon/test_drop.equ"] = "[name]\n`Test Equipment`\n[durability]\n50\n[attach type]\n`[trade]`\n"
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{"fatigue": 100},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "99",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 99}
runtime, _, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
return service, runtime, repositories
}
func currentDungeonPickupTestBody(dropObjectKey uint32) []byte {
body := make([]byte, dungeoncmd.GetItemRequestSize)
binary.LittleEndian.PutUint32(body[0:4], dropObjectKey)
body[5] = 7
for index, value := range []uint16{100, 200, 300, 400, 500, 600, 700} {
binary.LittleEndian.PutUint16(body[6+index*2:], value)
}
return body
}
func currentDungeonGoldDropTestRuleText() string {
return `[drop prob count]
1
[drop prob]
1 200 10000 0 0 0 0
[basis of rarity dicision]
700000 964900 990000 1000000 1000001 1000002 1000003
545500 899500 999500 1000000 1000001 1000002 1000003
500000 944900 999900 1000000 1000001 1000002 1000003
700000 944900 999000 1000000 1000001 1000002 1000003
[party member drop bonusrate]
1 1 1 1 1 1.3 1.6 2 1 1.2 1.4 1.6 1 1.2 1.4 1.6 1 1.2 1.4 1.6
[dungeon difficulty drop bonusrate]
1.00 1.00 1.00 1.40 1.10 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00 1.00 1.40 1.60 2.20 2.00
[monster type drop bonusrate]
1.00 2.00 3.00 6.60 1.00 2.00 3.00 4.00 0.20 2.00 7.00 16.80 1.00 2.00 3.00 4.00 1.00 2.00 3.00 4.00
[item drop ref table]
1 0 3
[first boss/named mob hunting]
1 1
[condition rate]
95 5 93 7 90 10 85 15
[gold quantity]
1 2 4 6
[gold volume]
100 110
[item drop rarity control]
2 13 17 0 0 3 13 17 0 0
[/item drop rarity control]
`
}
type pickupFailingInventoryRepository struct {
dnfrepo.InventoryRepository
err error
}
type pickupBlockingInventoryRepository struct {
dnfrepo.InventoryRepository
once sync.Once
entered chan struct{}
release <-chan struct{}
deadline chan<- time.Duration
calls atomic.Int32
}
func (repository *pickupBlockingInventoryRepository) Save(
ctx context.Context,
record dnfrepo.InventoryRecord,
) error {
repository.calls.Add(1)
if deadline, ok := ctx.Deadline(); ok && repository.deadline != nil {
select {
case repository.deadline <- time.Until(deadline):
default:
}
}
repository.once.Do(func() { close(repository.entered) })
select {
case <-repository.release:
case <-ctx.Done():
return ctx.Err()
}
return repository.InventoryRepository.Save(ctx, record)
}
func (repository pickupFailingInventoryRepository) Save(context.Context, dnfrepo.InventoryRecord) error {
return repository.err
}
type pickupTestCharacterItemUnitOfWork struct {
base dnfrepo.CharacterItemUnitOfWork
inventory dnfrepo.InventoryRepository
equipment dnfrepo.EquipmentRepository
}
func (unit pickupTestCharacterItemUnitOfWork) WithinCharacterItems(
ctx context.Context,
characterID string,
apply func(dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
) error {
return unit.base.WithinCharacterItems(ctx, characterID, func(
_ dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
return apply(unit.inventory, unit.equipment)
})
}