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

2072 lines
78 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"math"
"reflect"
"strconv"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
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 TestSendAlignedUpperResponsesSuppressesUnsafeEquipmentSlotPostActionBeforeMode0Appearance(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 1,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"13": {
SlotIndex: 13,
ItemID: 100312425,
RawEntry: make([]byte, currentItemListEntryWireSize),
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}, Warehouse: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-equipment-post-action",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 19,
townActorOwnerChannel: 19,
}
ackBody := []byte{1, 0, 9, 0, 1, 0, 0, 0, 3, 12, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: 19,
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedItemContainers,
alignedcmd.PostActionRefreshSelectedEquipmentSlots,
alignedcmd.PostActionRefreshSelectedActorAppearance,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
first, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if first.Header.MsgID != 19 || first.Header.Classification != dnfproto.DefaultChannelClassification || len(first.Body) != len(ackBody)+3 || !bytes.Equal(first.Body[:3], []byte{0, 0, 0}) || !bytes.Equal(first.Body[3:], ackBody) {
t.Fatalf("first packet header=%+v body=%x", first.Header, first.Body)
}
for _, wantListType := range currentSelectedItemListTypes {
refresh, remaining := splitCurrentSceneItemListPacket(t, rest)
if refresh.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || refresh.Header.Classification != 0 || len(refresh.Body) == 0 || refresh.Body[0] != wantListType {
t.Fatalf("list%d refresh header=%+v body=%x", wantListType, refresh.Header, refresh.Body)
}
rest = remaining
}
// The rebuilt containers drop the client's slot-keyed lock model, so the
// container post action pairs its op13 lists with the durable op251 snapshot
// the same way the select-character bootstrap does. This inventory holds no
// locked row, which still requires the explicit empty list.
lockSnapshot, rest := splitGameServerUpperPacket(t, rest)
if lockSnapshot.Header.MsgID != dnfitemlock.LockListMessageID || lockSnapshot.Header.Classification != 0 ||
!bytes.Equal(lockSnapshot.Body, []byte{0, 0, 0, 0, 0}) {
t.Fatalf("lock snapshot header=%+v body=%x", lockSnapshot.Header, lockSnapshot.Body)
}
second, rest := splitGameServerUpperPacket(t, rest)
const codecPrefix = 3
const currentObjectKeyOffset = 0x4c
if second.Header.MsgID != 2 || second.Header.Classification != 0 ||
len(second.Body) < codecPrefix+currentObjectKeyOffset+2 ||
!bytes.Equal(second.Body[:codecPrefix], []byte{0, 0, 0}) ||
second.Body[codecPrefix] != 0 ||
binary.LittleEndian.Uint16(second.Body[codecPrefix+currentObjectKeyOffset:]) != 19 {
t.Fatalf("second packet header=%+v body=%x", second.Header, second.Body)
}
if len(rest) != 0 {
t.Fatalf("unexpected trailing bytes: %x", rest)
}
}
func TestSortItemAckThenAuthoritativeList0KeepsGoldAndAccountCrystals(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:sort-owner",
Name: "SortOwner",
Job: "0",
Level: 90,
Stats: map[string]int64{"gold": 106241},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:12": {ItemID: 200, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
"0:20": {ItemID: 100, Count: 1, Extra: map[string]string{"item_kind": "avatar"}},
},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
crystalRaw := make([]byte, currentItemListEntryWireSize)
crystalRaw[0x59] = 0x5A
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:sort-owner",
Slots: map[string]dnfrepo.ItemStack{
"0:354": {ItemID: 3033, Count: 13, RawEntry: crystalRaw},
},
}); err != nil {
t.Fatal(err)
}
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketSortItem),
Body: []byte{6, 0, 0, 0, 16, 0, 24, 1, 32, 0},
AccountID: "dnf:sort-owner",
SelectedCharacterID: 19,
Repositories: repositories,
})
if err != nil || !routed {
t.Fatalf("route sort item routed=%t err=%v", routed, err)
}
if !result.Handled || !result.ResponseAllowed || len(result.UpperResponses) != 1 ||
len(result.PostActions) != 1 || result.PostActions[0] != alignedcmd.PostActionRefreshSelectedItemContainers {
t.Fatalf("sort result=%+v", result)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:sort-owner",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "sort-authoritative-list0",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error=%v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSortItem) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
len(ack.Body) != 12 || !bytes.Equal(ack.Body[3:], []byte{1, 4, 0, 0, 0, 8, 0, 16, 0}) {
t.Fatalf("sort ACK header=%+v body=%x", ack.Header, ack.Body)
}
mainList, rest := splitCurrentSceneItemListPacket(t, rest)
if len(mainList.Body) < 5 || mainList.Body[0] != dnfrepo.MainInventoryListType {
t.Fatalf("main list body=%x", mainList.Body)
}
rows := decodeCurrentItemListTestRows(t, mainList.Body[5:])
wantRows := map[uint16]struct {
itemID uint32
count uint32
}{
0: {itemID: 0, count: 106241},
9: {itemID: 100, count: 1},
10: {itemID: 200, count: 1},
354: {itemID: 3033, count: 13},
}
for _, row := range rows {
slot := binary.LittleEndian.Uint16(row[0:2])
want, ok := wantRows[slot]
if !ok {
continue
}
if itemID := binary.LittleEndian.Uint32(row[2:6]); itemID != want.itemID {
t.Fatalf("slot %d item=%d want=%d row=%x", slot, itemID, want.itemID, row[:16])
}
if count := binary.LittleEndian.Uint32(row[6:10]); count != want.count {
t.Fatalf("slot %d count=%d want=%d row=%x", slot, count, want.count, row[:16])
}
delete(wantRows, slot)
}
if len(wantRows) != 0 {
t.Fatalf("authoritative list0 omitted slots=%v rows=%d body=%x", wantRows, len(rows), mainList.Body)
}
for range currentSelectedItemListTypes[1:] {
_, rest = splitCurrentSceneItemListPacket(t, rest)
}
// Sorting moves whole rows; the client's slot-keyed lock icons would stay on
// the vacated slots, so the rebuilt containers are followed by the durable
// op251 snapshot (empty here: no locked row in this fixture).
lockSnapshot, rest := splitGameServerUpperPacket(t, rest)
if lockSnapshot.Header.MsgID != dnfitemlock.LockListMessageID || lockSnapshot.Header.Classification != 0 {
t.Fatalf("lock snapshot header=%+v body=%x", lockSnapshot.Header, lockSnapshot.Body)
}
if len(rest) != 0 {
t.Fatalf("unexpected trailing packets=%x", rest)
}
}
// TestAccountSharedWithdrawalPublishesEmptyRowForEmptiedCrystalSlot 钉住"搬空晶体/灵魂
// 仓库一格要在 ACK 之后补一行 (itemID=0, count=0) 的'空格'行"。
//
// 现场 2026-09-17:把无色小晶块(3037)从晶体仓库(list 0 slot 358)拖到个人仓库之后,
// 客户端右侧晶体面板仍然显示原来的 40449,而且那一行点不动(东西其实已经不在仓库里)。
// 运营的对照很关键:**卖出晶体、拍卖行上架、摆摊交易都能正确减少晶体仓库数量**,只有拖拽
// 不行 —— 因为那三条路径清空这一格用的是 (itemID=0, count=0)(见拍卖行的
// sendCurrentAuctionInventorySlot 与摆摊 private_store 的 sourceUpdate),而拖拽路径发的是
// 0xFFFFFFFF 删除行,右侧晶体/灵魂面板不认它(0xFFFFFFFF 只对角色背包格有效)。只搬一部分
// 时不能补行,否则会多出一行(2026-09-13 的重复显示)。
func TestAccountSharedWithdrawalPublishesEmptyRowForEmptiedCrystalSlot(t *testing.T) {
ctx := context.Background()
crystalSlot := int16(dnfrepo.CrystalWarehouseFirstSlot + 4) // 358 = 无色小晶块
cases := []struct {
name string
moveCount uint32
wantRefreshes []alignedcmd.ItemSlotRefresh
wantCrystalCount int64
}{
{
name: "full withdrawal empties the cell",
moveCount: 2_000,
wantRefreshes: []alignedcmd.ItemSlotRefresh{{ListType: dnfrepo.MainInventoryListType, SlotIndex: crystalSlot}},
wantCrystalCount: 0,
},
{
name: "partial withdrawal keeps the cell occupied",
moveCount: 1_000,
wantRefreshes: nil,
wantCrystalCount: 1_000,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:crystal-emptied",
Name: "CrystalEmptied",
Job: "0",
Level: 90,
Stats: map[string]int64{"gold": 1_000},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:crystal-emptied",
Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(crystalSlot): {
ItemID: 3037,
Count: 2_000,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": "stackable/material/cube_clear.stk",
"stackable_type": "[material]",
"stack_limit": "99999",
},
},
},
}); err != nil {
t.Fatal(err)
}
body := make([]byte, 28)
body[0] = dnfrepo.MainInventoryListType
binary.LittleEndian.PutUint16(body[1:3], uint16(crystalSlot))
binary.LittleEndian.PutUint32(body[7:11], testCase.moveCount)
body[11] = currentEquipmentPlacementListPersonalCargo
binary.LittleEndian.PutUint16(body[12:14], uint16(8))
binary.LittleEndian.PutUint32(body[22:26], math.MaxUint32)
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
Body: body,
AccountID: "dnf:crystal-emptied",
SelectedCharacterID: 19,
Repositories: repositories,
})
if err != nil || !routed {
t.Fatalf("route crystal withdrawal routed=%t err=%v", routed, err)
}
if len(result.ItemSlotRefreshes) != len(testCase.wantRefreshes) {
t.Fatalf("incremental slot refreshes = %+v, want %+v",
result.ItemSlotRefreshes, testCase.wantRefreshes)
}
for index := range testCase.wantRefreshes {
if result.ItemSlotRefreshes[index] != testCase.wantRefreshes[index] {
t.Fatalf("incremental slot refresh[%d] = %+v, want %+v",
index, result.ItemSlotRefreshes[index], testCase.wantRefreshes[index])
}
}
// 晶体/灵魂那一格的重绘只走"钱包 + 仍占用的晶体/灵魂行",不重建整表容器快照
// (拍卖行、摆摊那两条能正常减少数量的路径也只发一行)。
gotContainersRefresh := false
gotWalletsRefresh := false
for _, action := range result.PostActions {
switch action {
case alignedcmd.PostActionRefreshSelectedItemContainers:
gotContainersRefresh = true
case alignedcmd.PostActionRefreshSelectedMainWallets:
gotWalletsRefresh = true
}
}
if !gotWalletsRefresh {
t.Fatalf("crystal move post actions = %+v, want the wallet/crystal row repaint", result.PostActions)
}
if gotContainersRefresh {
t.Fatalf("crystal move post actions = %+v, must not rebuild every container", result.PostActions)
}
account, _, err := repositories.AccountInventory.Load(ctx, "dnf:crystal-emptied")
if err != nil {
t.Fatal(err)
}
key := dnfrepo.AccountSharedInventorySlotKey(crystalSlot)
if got := account.Slots[key].Count; got != testCase.wantCrystalCount {
t.Fatalf("account crystal count = %d, want %d", got, testCase.wantCrystalCount)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:crystal-emptied",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "crystal-emptied-" + testCase.name,
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error=%v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) {
t.Fatalf("crystal withdrawal ACK header=%+v", ack.Header)
}
if len(testCase.wantRefreshes) == 0 {
// 部分取出:ACK 之后直接是钱包重绘,不应出现 358 的任何行。
wallets, trailing := splitGameServerUpperPacket(t, rest)
if wallets.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || len(trailing) != 0 {
t.Fatalf("partial withdrawal packets: wallets=%+v trailing=%x", wallets.Header, trailing)
}
return
}
// 顺序:ACK → (itemID=0, count=0) 的空格行 → 钱包/占用行重绘,且再无其它包。
rows, trailing := splitGameServerUpperPacket(t, rest)
if rows.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
rows.Header.Classification != 0 || len(rows.Body) < 6 {
t.Fatalf("crystal empty-row packet header=%+v body=%x", rows.Header, rows.Body)
}
if rows.Body[3] != dnfrepo.MainInventoryListType {
t.Fatalf("crystal empty-row list=%d body=%x", rows.Body[3], rows.Body)
}
emptyRows := decodeCurrentItemListTestRows(t, rows.Body[6:])
if len(emptyRows) != 1 {
t.Fatalf("crystal empty-cell refresh rows=%d want 1 body=%x", len(emptyRows), rows.Body)
}
if gotSlot, gotItem, gotCount := binary.LittleEndian.Uint16(emptyRows[0][0x00:0x02]), binary.LittleEndian.Uint32(emptyRows[0][0x02:0x06]), binary.LittleEndian.Uint32(emptyRows[0][0x06:0x0A]); gotSlot != uint16(crystalSlot) || gotItem != 0 || gotCount != 0 {
t.Fatalf("crystal empty-cell row slot=%d item=%d count=%d want slot=%d item=0 count=0",
gotSlot, gotItem, gotCount, crystalSlot)
}
wallets, trailing := splitGameServerUpperPacket(t, trailing)
if wallets.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("crystal move must repaint the wallet/crystal rows, got %+v", wallets.Header)
}
if len(trailing) != 0 {
t.Fatalf("crystal move must not rebuild every container, trailing=%x", trailing)
}
})
}
}
// TestWarehouseItemRetrievedFromPersonalCargoLandsInCrystalSlot 钉住运营口径
// 2026-09-17:"只要是放回背包,就一定要放回晶体仓库"。
//
// 现场:把无色小晶块从个人仓库取回时,客户端把落点声明成材料背包格(0:156 / 0:121 /
// 0:150),于是晶体落在背包里、晶体仓库那一格还是空的。晶体/灵魂类道具(六个晶块
// 354..359、六个灵魂 360..365)在自己账号里只有一个固定格,服务端要按 ACK 回显真实
// 落点(op19 的 RedirectedReservedMainSlot),随后重绘那两排。
func TestWarehouseItemRetrievedFromPersonalCargoLandsInCrystalSlot(t *testing.T) {
ctx := context.Background()
clearSlot := int16(dnfrepo.CrystalWarehouseFirstSlot + 4) // 358 = 无色小晶块
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:cube-home",
Name: "CubeHome",
Job: "0",
Level: 90,
Stats: map[string]int64{"gold": 1_000},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
// 个人仓库 2:43 放 40 个无色小晶块(客户端取回时声明的落点是背包 0:121)。
record, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
record.Warehouse = map[string]dnfrepo.ItemStack{
"2:43": {
ItemID: 3037,
Count: 40,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": "stackable/material/cube_clear.stk",
"stackable_type": "[material]",
"stack_limit": "99999",
},
},
}
if err := repositories.Inventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:cube-home",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
body := make([]byte, 28)
body[0] = 2 // personal cargo
binary.LittleEndian.PutUint16(body[1:3], 43)
binary.LittleEndian.PutUint32(body[7:11], 40)
body[11] = dnfrepo.MainInventoryListType
binary.LittleEndian.PutUint16(body[12:14], 121)
binary.LittleEndian.PutUint32(body[22:26], math.MaxUint32)
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
Body: body,
AccountID: "dnf:cube-home",
SelectedCharacterID: 19,
Repositories: repositories,
AccountWarehouseSlotResolver: currentAccountWarehouseFixedSlot,
StackMergeResolver: func(itemID int64) (alignedcmd.StackMergeResolution, error) {
return alignedcmd.StackMergeResolution{
ItemID: itemID, Kind: "stackable",
PVFPath: "stackable/material/cube_clear.stk", StackLimit: 99_999,
SlotStart: 120, SlotEnd: 151,
}, nil
},
})
if err != nil || !routed {
t.Fatalf("route cube retrieval routed=%t err=%v", routed, err)
}
if !result.ResponseAllowed {
t.Fatalf("cube retrieval refused: %+v", result)
}
// 晶体归位后要重绘晶体/灵魂两排(ACK 自己不重绘那个面板)。
foundWalletRefresh := false
for _, action := range result.PostActions {
if action == alignedcmd.PostActionRefreshSelectedMainWallets {
foundWalletRefresh = true
}
}
if !foundWalletRefresh {
t.Fatalf("cube retrieval post actions = %+v, want the crystal/soul widget repaint", result.PostActions)
}
account, _, err := repositories.AccountInventory.Load(ctx, "dnf:cube-home")
if err != nil {
t.Fatal(err)
}
key := dnfrepo.AccountSharedInventorySlotKey(clearSlot)
if stack := account.Slots[key]; stack.ItemID != 3037 || stack.Count != 40 {
t.Fatalf("crystal warehouse after retrieval = %+v, want 3037 x40 at slot %d", stack, clearSlot)
}
record, _, err = repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if _, exists := record.Warehouse["2:43"]; exists {
t.Fatalf("personal cargo still holds the cube: %+v", record.Warehouse)
}
if _, exists := record.Slots["0:121"]; exists {
t.Fatalf("cube landed in the declared backpack slot: %+v", record.Slots)
}
// ACK 必须回显真实落点 0:358,客户端据此重画;否则它会把晶体画在背包 0:121。
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:cube-home",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection, connID: "cube-home-retrieval",
channel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 19,
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error=%v", err)
}
ack, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) || len(ack.Body) < 11 {
t.Fatalf("cube retrieval ACK header=%+v body=%x", ack.Header, ack.Body)
}
// server16 头模式会在 class-1 包体前面带固定的 3 个 0 字节;op19 布局是最后 11 字节。
ackBody := ack.Body[len(ack.Body)-11:]
if ackBody[0] != 1 {
t.Fatalf("cube retrieval ACK is not a success: %x", ackBody)
}
if gotSrcList, gotSrcSlot := ackBody[1], binary.LittleEndian.Uint16(ackBody[2:4]); gotSrcList != 2 || gotSrcSlot != 43 {
t.Fatalf("cube retrieval ACK source = %d:%d, want 2:43 (body=%x)", gotSrcList, gotSrcSlot, ackBody)
}
if gotCount := binary.LittleEndian.Uint32(ackBody[4:8]); gotCount != 40 {
t.Fatalf("cube retrieval ACK count = %d, want 40 (body=%x)", gotCount, ackBody)
}
if gotDstList, gotDstSlot := ackBody[8], binary.LittleEndian.Uint16(ackBody[9:11]); gotDstList != dnfrepo.MainInventoryListType || gotDstSlot != uint16(clearSlot) {
t.Fatalf("cube retrieval ACK destination = %d:%d, want %d:%d (body=%x)",
gotDstList, gotDstSlot, dnfrepo.MainInventoryListType, clearSlot, ackBody)
}
}
func TestPersonalCargoDepositRestoresGoldAndAccountSharedRowsAfterAck(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:cargo-wallets",
Name: "CargoWallets",
Job: "0",
Level: 90,
Stats: map[string]int64{"gold": 7_268_086},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
stack := func(itemID, count int64) dnfrepo.ItemStack {
return dnfrepo.ItemStack{
ItemID: itemID,
Count: count,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": "stackable/material/cargo_wallet_test.stk",
"stackable_type": "[material]",
"stack_limit": "99999",
},
}
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:cargo-wallets",
Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(354): stack(3033, 73),
dnfrepo.AccountSharedInventorySlotKey(358): stack(3037, 2_000),
dnfrepo.AccountSharedInventorySlotKey(362): stack(10099773, 115),
},
}); err != nil {
t.Fatal(err)
}
body := make([]byte, 28)
body[0] = dnfrepo.MainInventoryListType
binary.LittleEndian.PutUint16(body[1:3], uint16(358))
binary.LittleEndian.PutUint32(body[7:11], uint32(1_000))
body[11] = currentEquipmentPlacementListPersonalCargo
binary.LittleEndian.PutUint16(body[12:14], uint16(8))
binary.LittleEndian.PutUint32(body[22:26], math.MaxUint32)
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
Body: body,
AccountID: "dnf:cargo-wallets",
SelectedCharacterID: 19,
Repositories: repositories,
})
if err != nil || !routed {
t.Fatalf("route cargo deposit routed=%t err=%v", routed, err)
}
if !result.Handled || !result.ResponseAllowed ||
!reflect.DeepEqual(result.PostActions, []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedMainWallets}) {
t.Fatalf("cargo deposit result=%+v", result)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:cargo-wallets",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "cargo-wallet-restore",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error=%v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification {
t.Fatalf("move ACK header=%+v body=%x", ack.Header, ack.Body)
}
wallets, trailing := splitGameServerUpperPacket(t, rest)
if wallets.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
wallets.Header.Classification != 0 || len(wallets.Body) < 6 ||
!bytes.Equal(wallets.Body[:3], []byte{0, 0, 0}) ||
wallets.Body[3] != dnfrepo.MainInventoryListType {
t.Fatalf("wallet refresh header=%+v body=%x", wallets.Header, wallets.Body)
}
walletBody := wallets.Body[3:]
rows := decodeCurrentItemListTestRows(t, walletBody[3:])
want := map[uint16]struct {
itemID uint32
count uint32
}{
0: {itemID: 0, count: 7_268_086},
354: {itemID: 3033, count: 73},
358: {itemID: 3037, count: 1_000},
362: {itemID: 10099773, count: 115},
}
for _, row := range rows {
slot := binary.LittleEndian.Uint16(row[0:2])
expected, ok := want[slot]
if !ok {
t.Fatalf("unexpected wallet slot=%d row=%x", slot, row[:16])
}
if itemID := binary.LittleEndian.Uint32(row[2:6]); itemID != expected.itemID {
t.Fatalf("wallet slot=%d item=%d want=%d", slot, itemID, expected.itemID)
}
if count := binary.LittleEndian.Uint32(row[6:10]); count != expected.count {
t.Fatalf("wallet slot=%d count=%d want=%d", slot, count, expected.count)
}
delete(want, slot)
}
if len(want) != 0 || len(trailing) != 0 {
t.Fatalf("wallet refresh missing=%v trailing=%x", want, trailing)
}
account, found, err := repositories.AccountInventory.Load(ctx, "dnf:cargo-wallets")
if err != nil || !found || account.Slots[dnfrepo.AccountSharedInventorySlotKey(358)].Count != 1_000 {
t.Fatalf("account crystal after deposit found=%t account=%+v err=%v", found, account, err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || inventory.Warehouse["2:8"].ItemID != 3037 || inventory.Warehouse["2:8"].Count != 1_000 {
t.Fatalf("personal cargo after deposit found=%t inventory=%+v err=%v", found, inventory, err)
}
}
// TestAlignedCargoPadResetPostActionSendsOp391 扩容类操作(开通/升级账号金库、仓库扩容券)
// 在 ACK 之后必须先补一发 cargo-pad 重置(op391),否则客户端面板上新解锁的格子还画着锁
// (现场 2026-09-18:扩大账号金库后显示仍锁、实际能放东西,切一下仓库页才正常)。
func TestAlignedCargoPadResetPostActionSendsOp391(t *testing.T) {
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:cargo-pad", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
}
session := &gameSession{
conn: connection,
connID: "cargo-pad-reset",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendAlignedPostAction(
session,
"upgrade_account_cargo",
alignedcmd.PostActionResetCargoPadBeforeItemLists,
); err != nil {
t.Fatalf("sendAlignedPostAction: %v", err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketCancelCargoPad) || packet.Header.Classification != 0 {
t.Fatalf("packet=%+v, 期望 op391 class0", packet.Header)
}
wantTail := bytes.Repeat([]byte{0xff}, currentCancelCargoPadProtectedTailSize)
if !bytes.HasSuffix(packet.Body, wantTail) {
t.Fatalf("body 缺少 cargo-pad 保护尾: % X", packet.Body)
}
}
func TestPersonalCargoSortRefreshesOnlyList2AndDoesNotReplayBackpack(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 9999, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
},
Warehouse: map[string]dnfrepo.ItemStack{
"2:7": {ItemID: 10008121, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
"2:5": {ItemID: 7273, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: dnfrepo.CharacterContainerStateScope("19"),
Values: map[string]string{
"main_list_param16": "24",
"personal_cargo_list_param16": "8",
},
}); err != nil {
t.Fatal(err)
}
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand), CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketSortItem), Body: []byte{2, 11, 0},
AccountID: "dnf:cargo-sort", SelectedCharacterID: 19, Repositories: repositories,
})
if err != nil || !routed {
t.Fatalf("route routed=%t err=%v", routed, err)
}
if len(result.PostActions) != 1 || result.PostActions[0] != alignedcmd.PostActionRefreshSelectedPersonalCargo {
t.Fatalf("result=%+v", result)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:cargo-sort", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "cargo-sort-list2-only", channel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 19}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSortItem) || ack.Header.Classification != dnfproto.DefaultChannelClassification {
t.Fatalf("ack=%+v", ack.Header)
}
cargo, rest := splitCurrentSceneItemListPacket(t, rest)
if len(cargo.Body) < 6 || cargo.Body[0] != 2 {
t.Fatalf("cargo body=%x", cargo.Body)
}
rows := decodeCurrentItemListTestRows(t, cargo.Body[5:len(cargo.Body)-1])
if len(rows) != 2 || binary.LittleEndian.Uint16(rows[0][0:2]) != 0 || binary.LittleEndian.Uint32(rows[0][2:6]) != 7273 ||
binary.LittleEndian.Uint16(rows[1][0:2]) != 1 || binary.LittleEndian.Uint32(rows[1][2:6]) != 10008121 {
t.Fatalf("cargo rows=%x", rows)
}
// The rebuilt list-2 container drops the client's slot-keyed lock model, so
// the single-container refresh is followed by the durable op251 snapshot
// and nothing else (no list0 replay).
lockSnapshot, rest := splitGameServerUpperPacket(t, rest)
if lockSnapshot.Header.MsgID != dnfitemlock.LockListMessageID || lockSnapshot.Header.Classification != 0 {
t.Fatalf("lock snapshot header=%+v body=%x", lockSnapshot.Header, lockSnapshot.Body)
}
if len(rest) != 0 {
t.Fatalf("unexpected packet after list2 (list0 replay regression): %x", rest)
}
}
// 宠物本体/宠物装备的穿脱只重建 list 7。list 0/1/2/38 在这次操作里一个字节都不会变,
// 但重发它们代价极大:当前 EXE 在 op19 ACK 之后要先把已经收到的整批容器表处理完,才
// 轮到宠物行与 mode0。2026-09-24 公网实测(30 秒 op1206 心跳标定的时钟差恒定 −4.384s、
// 抖动 ±10ms,说明链路没有在途延迟):ACK 到达 11ms 就被处理,宠物行与 mode0 却晚了
// 1.65~1.71 秒——这 1.7 秒就是客户端在重建背包页;本机同一套表只要 ~70ms,因为环回的
// 电报一次性到达、客户端只重建一次。现场表现就是"脱下后背包图标要等一下才出现"
// "穿上后要几秒宠物才出现"。这个测试锁死"只发一张 list 7",防止退回五表刷新。
func TestWornCreatureInventoryPostActionRebuildsOnlyList7(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
// list 0 特意放一行:以前它会陪跑重发(背包整表),现在必须一个包都不发。
"0:5": {ItemID: 9999, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
"7:10": {ItemID: 400990199, Count: 1, Extra: map[string]string{"item_kind": "creature"}},
},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:pet-move",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "pet-move-list7-only",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendAlignedPostAction(
session,
"move_itemspace",
alignedcmd.PostActionRefreshSelectedWornCreatureInventory,
); err != nil {
t.Fatalf("sendAlignedPostAction: %v", err)
}
petList, rest := splitCurrentSceneItemListPacket(t, connection.write.Bytes())
// list 7 的体是 listType(1) + count(u16) + 行,没有 list 2 那种分组尾。
if len(petList.Body) < 3 || petList.Body[0] != currentPetInventoryListType {
t.Fatalf("pet list body=%x", petList.Body)
}
if got := binary.LittleEndian.Uint16(petList.Body[1:3]); got != 1 {
t.Fatalf("pet list entry count=%d body=%x", got, petList.Body)
}
rows := decodeCurrentItemListTestRows(t, petList.Body[3:])
if len(rows) != 1 {
t.Fatalf("pet rows=%x", rows)
}
assertCurrentItemListTestRow(t, rows[0], 10, 400990199, 1)
// op13 重建会丢掉客户端进程内的锁标记,所以单表重建之后照旧补发 op251。
lockSnapshot, rest := splitGameServerUpperPacket(t, rest)
if lockSnapshot.Header.MsgID != dnfitemlock.LockListMessageID || lockSnapshot.Header.Classification != 0 {
t.Fatalf("lock snapshot header=%+v body=%x", lockSnapshot.Header, lockSnapshot.Body)
}
if len(rest) != 0 {
t.Fatalf("unexpected packet after list7 (unrelated container replay regression): %x", rest)
}
}
// 宠物穿脱必须继续刷新战斗力:宠物属性会改变战斗力,少发 op413 客户端的战斗力面板就
// 停在上一次的值。这个断言与上面的"只重建 list 7"配套,防止收窄容器表时连战斗力投影
// 一起丢掉。
func TestWornCreatureInventoryPostActionStillTouchesCombatPower(t *testing.T) {
if string(alignedcmd.PostActionRefreshSelectedWornCreatureInventory) != "refresh_selected_worn_creature_inventory" {
t.Fatalf("post action wire value=%q", alignedcmd.PostActionRefreshSelectedWornCreatureInventory)
}
if !alignedPostActionTouchesCombatPower(alignedcmd.PostActionRefreshSelectedWornCreatureInventory) {
t.Fatal("worn creature inventory refresh must still schedule the combat-power projection")
}
// 普通装备/背包路径的集合不能被这次收窄改动影响。
if !alignedPostActionTouchesCombatPower(alignedcmd.PostActionRefreshSelectedItemContainers) {
t.Fatal("shared container refresh must keep scheduling the combat-power projection")
}
}
func TestSendAlignedUpperResponsesKeepsNativeSkinAvatarMoveAckThenMode0Only(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "20",
AccountID: "dnf:1",
Name: "Actor20",
Job: "0",
Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "20",
Entries: map[string]dnfrepo.EquipmentEntry{
"8": {
SlotIndex: 8,
ItemID: 112580095,
RawEntry: make([]byte, currentItemListEntryWireSize),
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "20",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "native-equipment-move-projection",
channel: channelcatalog.Channel{ID: 20},
selectedCharacterID: 20,
initialTownRouteCharacterID: 20,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 20,
connectionTownActorOwnerChannel: 20,
townActorOwnerChannel: 20,
}
ackBody := []byte{1, 0, 31, 0, 1, 0, 0, 0, 3, 12, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketMoveItemspace),
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedActorAppearance,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
len(ack.Body) != len(ackBody)+3 ||
!bytes.Equal(ack.Body[:3], []byte{0, 0, 0}) ||
!bytes.Equal(ack.Body[3:], ackBody) {
t.Fatalf("move ACK header=%+v body=%x", ack.Header, ack.Body)
}
mode0, trailing := splitGameServerUpperPacket(t, rest)
if mode0.Header.MsgID != 2 || mode0.Header.Classification != 0 ||
len(mode0.Body) < 4 || !bytes.Equal(mode0.Body[:3], []byte{0, 0, 0}) ||
mode0.Body[3] != 0 {
t.Fatalf("mode0 header=%+v body=%x", mode0.Header, mode0.Body)
}
if eventArg, ok := currentSceneObjectTailActorStateEventArgForLog(mode0.Body[3:]); !ok || eventArg != 0 {
t.Fatalf("post-wear mode0 actor-state event arg=%d ok=%t, want suppressed zero", eventArg, ok)
}
if skinItemID, ok := currentSceneObjectAppearanceSlotItemID(mode0.Body[3:], 8); !ok || skinItemID != 112580095 {
t.Fatalf("post-wear mode0 skin row=%d ok=%t, want 112580095", skinItemID, ok)
}
if len(trailing) != 0 {
t.Fatalf("ordinary equipment move emitted container/list3 packets after mode0: %x", trailing)
}
}
func TestSendAlignedUpperResponsesDoesNotSendPartialListsOrStaleMode0WhenRefreshPlanFails(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "0",
Level: 1,
Stats: map[string]int64{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{},
}); err != nil {
t.Fatal(err)
}
failing := &failAfterInventoryLoads{
InventoryRepository: repositories.Inventory,
failAfter: 2,
}
repositories.Inventory = failing
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-refresh-plan-failure",
channel: channelcatalog.Channel{ID: 253},
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 253,
townActorOwnerChannel: 253,
}
ackBody := []byte{1, 0, 9, 0, 1, 0, 0, 0, 3, 11, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: 19,
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedItemContainers,
alignedcmd.PostActionRefreshSelectedActorAppearance,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("post-commit projection failure disconnected session: %v", err)
}
ack, trailing := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != 19 ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, ackBody) {
t.Fatalf("ACK header=%+v body=%x", ack.Header, ack.Body)
}
if len(trailing) != 0 {
t.Fatalf("failed five-list plan leaked partial list or stale mode0=%x", trailing)
}
if failing.loads <= failing.failAfter {
t.Fatalf("inventory failure was not exercised loads=%d", failing.loads)
}
}
func TestSendAlignedUpperResponsesKeepsCommittedAckWhenMode0WearStatusCannotLoadCharacter(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "20",
AccountID: "dnf:1",
Name: "Actor19",
Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {SlotIndex: 26, ItemID: 9001},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-equipment-missing-actor-post-action",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 19,
townActorOwnerChannel: currentSceneObjectContext,
}
ackBody := []byte{1, 0, 9, 0, 1, 0, 0, 0, 3, 12, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: 19,
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedActorAppearance,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("committed op19 ACK became fatal after deferred refresh: %v", err)
}
ack, trailing := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != 19 ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, ackBody) ||
len(trailing) != 0 {
t.Fatalf("committed ACK header=%+v body=%x trailing=%x", ack.Header, ack.Body, trailing)
}
}
func TestCurrentTownSelfActorAppearanceReadyRequiresFinalizedLocalActor(t *testing.T) {
session := &gameSession{
selectedCharacterID: 19,
townSceneReadyCharacterID: 19,
townActorOwnerChannel: currentSceneObjectContext,
}
if !currentTownSelfActorAppearanceReady(session) {
t.Fatal("finalized local selected actor was not appearance-ready")
}
session.connectionTownActorOwnerChannel = 253
session.townActorOwnerChannel = 253
if !currentTownSelfActorAppearanceReady(session) {
t.Fatal("finalized CHANNELINFO-owned selected actor was not appearance-ready")
}
session.townActorOwnerChannel = 7
if currentTownSelfActorAppearanceReady(session) {
t.Fatal("remote scene-context actor was appearance-ready")
}
session.townActorOwnerChannel = 253
session.townSceneReadyCharacterID = 20
if currentTownSelfActorAppearanceReady(session) {
t.Fatal("stale different-character scene was appearance-ready")
}
}
func TestSelectedActorAppearanceRefreshClearsUnequippedSlotsWithMode0ThenFullMode1(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 90,
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-unequip-mode0-refresh",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendSelectedActorAppearanceRefresh(session, "move_itemspace", "test_unequip"); err != nil {
t.Fatal(err)
}
mode0Packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
const codecPrefix = 3
if mode0Packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
mode0Packet.Header.Classification != 0 ||
len(mode0Packet.Body) <= codecPrefix ||
!bytes.Equal(mode0Packet.Body[:codecPrefix], []byte{0, 0, 0}) {
t.Fatalf("unequip mode0 header=%+v body=%x", mode0Packet.Header, mode0Packet.Body)
}
assertCurrentTypedMode0FullAppearanceForTest(t, mode0Packet.Body[codecPrefix:], "Actor19", 3, currentActorMode0AppearanceEmptyItem)
mode1Packet, trailing := splitGameServerUpperPacket(t, rest)
if mode1Packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
mode1Packet.Header.Classification != 0 ||
len(mode1Packet.Body) <= codecPrefix+currentMode1CreateCountOffset ||
!bytes.Equal(mode1Packet.Body[:codecPrefix], []byte{0, 0, 0}) {
t.Fatalf("unequip full mode1 header=%+v body=%x", mode1Packet.Header, mode1Packet.Body)
}
mode1 := mode1Packet.Body[codecPrefix:]
if mode1[0] != 1 ||
binary.LittleEndian.Uint16(mode1[21:23]) != 19 ||
mode1[currentMode1CreateCountOffset] != 0 {
t.Fatalf("unequip full mode1 body=%x", mode1)
}
if len(trailing) != 0 {
t.Fatalf("unexpected unequip op13/op357/trailing=%x", trailing)
}
}
func TestSelectedActorMode1AppearanceRefreshClearsUnequippedSlots(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 90,
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-unequip-mode1-refresh",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendSelectedActorMode1AppearanceRefresh(session, "move_itemspace", "test_unequip"); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
const codecPrefix = 3
if packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
packet.Header.Classification != 0 ||
len(packet.Body) <= codecPrefix+currentMode1CreateCountOffset ||
!bytes.Equal(packet.Body[:codecPrefix], []byte{0, 0, 0}) {
t.Fatalf("unequip mode1 refresh header=%+v body=%x", packet.Header, packet.Body)
}
mode1 := packet.Body[codecPrefix:]
if mode1[0] != 1 ||
binary.LittleEndian.Uint16(mode1[21:23]) != 19 ||
mode1[currentMode1CreateCountOffset] != 0 {
t.Fatalf("unequip mode1 body=%x", mode1)
}
if len(trailing) != 0 {
t.Fatalf("unexpected unequip mode1 trailing=%x", trailing)
}
}
func TestSendAlignedUpperResponsesRefreshesOnlyUsedStackableRow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {
ItemID: 500,
Count: 108,
Extra: map[string]string{
"item_kind": "stackable",
"amount": "117",
"amount_or_count": "117",
},
},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "stackable-use-incremental", selectedCharacterID: 19}
ackBody := []byte{1, 5, 0, 0, 0x11, 0x22, 0x33, 0x44, 0xF4, 0x01, 0, 0}
result := alignedcmd.Result{
Operation: "use_stackable",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketUseStackable),
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{ListType: 0, SlotIndex: 5}},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketUseStackable) {
t.Fatalf("ack header=%+v", ack.Header)
}
update, trailing := splitGameServerUpperPacket(t, rest)
const codecPrefix = 3
updateBody := update.Body[codecPrefix:]
if update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
update.Header.Classification != 0 ||
len(updateBody) != 3+currentItemListEntryWireSize ||
updateBody[0] != 0 ||
binary.LittleEndian.Uint16(updateBody[1:3]) != 1 {
t.Fatalf("incremental update header=%+v body=%x", update.Header, update.Body)
}
row := updateBody[3:]
if binary.LittleEndian.Uint16(row[0:2]) != 5 ||
binary.LittleEndian.Uint32(row[2:6]) != 500 ||
binary.LittleEndian.Uint32(row[6:10]) != 108 {
t.Fatalf("incremental row=%x", row)
}
if len(trailing) != 0 {
t.Fatalf("unexpected full-list/trailing packets=%x", trailing)
}
}
func TestSendAlignedUpperResponsesRefreshesRepairAllEquipmentAsSingleRows(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
entries := make(map[string]dnfrepo.EquipmentEntry, 2)
for _, slot := range []int16{12, 14} {
itemID := int64(10000 + slot)
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], uint16(slot))
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], uint16(30+slot))
entries[strconv.Itoa(int(slot))] = dnfrepo.EquipmentEntry{
SlotIndex: slot,
ItemID: itemID,
RawEntry: raw,
}
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19", Entries: entries}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "repair-all-equipment-refresh", selectedCharacterID: 19}
result := alignedcmd.Result{
Operation: "repair_equipment",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketRepairEquipment),
Body: []byte{1, 0, 0, 0, 0, 3, 0xFF, 0xFF, 0, 0},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
EquipmentSlotRefreshes: []int16{12, 14},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketRepairEquipment) {
t.Fatalf("repair-all ACK header=%+v", ack.Header)
}
for _, wantSlot := range []int16{12, 14} {
update, remaining := splitGameServerUpperPacket(t, rest)
const codecPrefix = 3
body := update.Body[codecPrefix:]
if update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
update.Header.Classification != 0 ||
len(body) != 3+currentItemListEntryWireSize ||
body[0] != currentSocketListEquipment ||
binary.LittleEndian.Uint16(body[1:3]) != 1 {
t.Fatalf("slot %d update header=%+v body=%x", wantSlot, update.Header, update.Body)
}
row := body[3:]
if int16(binary.LittleEndian.Uint16(row[0:2])) != wantSlot ||
binary.LittleEndian.Uint32(row[2:6]) != uint32(10000+wantSlot) ||
binary.LittleEndian.Uint16(row[0x0B:0x0D]) != uint16(30+wantSlot) {
t.Fatalf("slot %d row=%x", wantSlot, row)
}
rest = remaining
}
if len(rest) != 0 {
t.Fatalf("repair-all emitted trailing/full-list packets=%x", rest)
}
}
func TestSendAlignedUpperResponsesRefreshesRemovedStackableAndReviveWalletRows(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:1": {ItemID: 1, Count: 4},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "revive-use-incremental", selectedCharacterID: 19}
result := alignedcmd.Result{
Operation: "use_stackable",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketUseStackable),
Body: []byte{1, 5, 0, 0, 0x11, 0x22, 0x33, 0x44, 42, 0, 0, 0},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{
{ListType: 0, SlotIndex: 5},
{ListType: 0, SlotIndex: 1},
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
_, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
update, trailing := splitGameServerUpperPacket(t, rest)
const codecPrefix = 3
updateBody := update.Body[codecPrefix:]
if len(updateBody) != 3+2*currentItemListEntryWireSize ||
binary.LittleEndian.Uint16(updateBody[1:3]) != 2 {
t.Fatalf("incremental update body=%x", update.Body)
}
removed := updateBody[3 : 3+currentItemListEntryWireSize]
wallet := updateBody[3+currentItemListEntryWireSize:]
if binary.LittleEndian.Uint16(removed[0:2]) != 5 ||
binary.LittleEndian.Uint32(removed[2:6]) != math.MaxUint32 ||
binary.LittleEndian.Uint32(removed[6:10]) != 0 {
t.Fatalf("removed source row=%x", removed)
}
if binary.LittleEndian.Uint16(wallet[0:2]) != 1 ||
binary.LittleEndian.Uint32(wallet[2:6]) != 1 ||
binary.LittleEndian.Uint32(wallet[6:10]) != 4 {
t.Fatalf("wallet row=%x", wallet)
}
if len(trailing) != 0 {
t.Fatalf("unexpected full-list/trailing packets=%x", trailing)
}
}
func TestSendAlignedUpperResponsesRefreshesCrossContainerMergeRows(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{
"2:8": {ItemID: 3227, Count: 5},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "cargo-merge-incremental", selectedCharacterID: 19}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketMoveItemspace),
Body: []byte{1, 0, 65, 0, 2, 0, 0, 0, 2, 8, 0},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{
{ListType: 0, SlotIndex: 65},
{ListType: 2, SlotIndex: 8},
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, append([]byte{0, 0, 0}, result.UpperResponses[0].Body...)) {
t.Fatalf("move ACK header=%+v body=%x", ack.Header, ack.Body)
}
sourceUpdate, rest := splitGameServerUpperPacket(t, rest)
destinationUpdate, trailing := splitGameServerUpperPacket(t, rest)
const codecPrefix = 3
sourceBody := sourceUpdate.Body[codecPrefix:]
destinationBody := destinationUpdate.Body[codecPrefix:]
if sourceUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(sourceBody) != 3+currentItemListEntryWireSize ||
sourceBody[0] != 0 ||
binary.LittleEndian.Uint16(sourceBody[1:3]) != 1 {
t.Fatalf("source update header=%+v body=%x", sourceUpdate.Header, sourceUpdate.Body)
}
source := sourceBody[3:]
if binary.LittleEndian.Uint16(source[0:2]) != 65 ||
binary.LittleEndian.Uint32(source[2:6]) != math.MaxUint32 ||
binary.LittleEndian.Uint32(source[6:10]) != 0 {
t.Fatalf("source deletion row=%x", source)
}
if destinationUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(destinationBody) != 3+currentItemListEntryWireSize ||
destinationBody[0] != 2 ||
binary.LittleEndian.Uint16(destinationBody[1:3]) != 1 {
t.Fatalf("destination update header=%+v body=%x", destinationUpdate.Header, destinationUpdate.Body)
}
destination := destinationBody[3:]
if binary.LittleEndian.Uint16(destination[0:2]) != 8 ||
binary.LittleEndian.Uint32(destination[2:6]) != 3227 ||
binary.LittleEndian.Uint32(destination[6:10]) != 5 {
t.Fatalf("destination merged row=%x", destination)
}
if len(trailing) != 0 {
t.Fatalf("unexpected full-list/trailing packets=%x", trailing)
}
}
func TestSendAlignedPetWearAppendsOnlyCreatureState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 1,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {
SlotIndex: 26,
ItemID: 63000,
RawEntry: testCurrentEquippedCreatureEnchantRaw(26, 63000, 37, 10008705, 0),
Extra: map[string]string{
"value_a": "10008705",
"pet_enchant_card_item_id": "10008705",
},
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37",
CreatureKey: 37,
ItemID: 63000,
Name: "Pet37",
NameRaw: []byte("Pet37"),
Satiety: 73,
Level: 3,
Exp: 12,
SourceListType: 3,
SourceSlotIndex: 26,
},
},
EquippedKey: "37",
TownDisplay: true,
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-pet-post-actions",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 19,
townActorOwnerChannel: 19,
selectedCreatureStateTableSent: true,
}
ackBody := []byte{1, 7, 0x30, 0, 1, 0, 0, 0, 17, 26, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: 19,
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedCreatureState,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != 19 || ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, ackBody) {
t.Fatalf("ack header=%+v body=%x", ack.Header, ack.Body)
}
itemRefresh, rest := splitLongHengGameServerUpperPacket(t, rest)
assertEquippedCreatureItemRefreshPacket(t, itemRefresh, 63000, 37, 10008705, 0)
state, rest := splitLongHengGameServerUpperPacket(t, rest)
wantStateBody := []byte{
1,
37, 0, 0, 0,
73,
0,
12, 0, 0, 0,
3,
5, 0, 0, 0, 'P', 'e', 't', '3', '7',
0,
}
if state.Header.MsgID != currentCreatureStateTableMsgID || state.Header.Classification != 0 ||
!bytes.Equal(state.Body, wantStateBody) {
t.Fatalf("op105 header=%+v body=%x want=%x", state.Header, state.Body, wantStateBody)
}
growth, rest := splitLongHengGameServerUpperPacket(t, rest)
wantGrowth := []byte{3, 0, 12, 0, 0, 0}
if growth.Header.MsgID != currentCreatureGrowthMsgID || growth.Header.Classification != 0 ||
!bytes.Equal(growth.Body, wantGrowth) {
t.Fatalf("growth header=%+v body=%x want=%x", growth.Header, growth.Body, wantGrowth)
}
if !session.selectedCreatureStateTableSent {
t.Fatal("op105 state was not marked sent after successful write")
}
if len(rest) != 0 {
t.Fatalf("pet wear appended packets after targeted slot26 op14/op105/op102=%x", rest)
}
}
func TestSendAlignedPetUnequipAppendsOnlyCreatureTableWithoutGrowth(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 1,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37",
CreatureKey: 37,
ItemID: 63000,
Name: "Pet37",
NameRaw: []byte("Pet37"),
Satiety: 73,
Level: 3,
Exp: 12,
SourceListType: 7,
SourceSlotIndex: 48,
},
},
EquippedKey: "",
TownDisplay: false,
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "aligned-pet-unequip-post-actions",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
townSceneReadyCharacterID: 19,
connectionTownActorOwnerChannel: 19,
townActorOwnerChannel: 19,
selectedCreatureStateTableSent: true,
}
ackBody := []byte{1, 7, 0x30, 0, 1, 0, 0, 0, 17, 26, 0}
result := alignedcmd.Result{
Operation: "move_itemspace",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: 19,
Body: ackBody,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{
alignedcmd.PostActionRefreshSelectedCreatureState,
},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != 19 ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, ackBody) {
t.Fatalf("ack header=%+v body=%x", ack.Header, ack.Body)
}
state, rest := splitLongHengGameServerUpperPacket(t, rest)
if state.Header.MsgID != currentCreatureStateTableMsgID ||
state.Header.Classification != 0 {
t.Fatalf("op105 header=%+v body=%x", state.Header, state.Body)
}
if len(rest) != 0 {
next, _ := splitLongHengGameServerUpperPacket(t, rest)
t.Fatalf("pet unequip appended packet op=%d body=%x; op102 must be absent", next.Header.MsgID, next.Body)
}
}
func TestRuntimeSceneReadySequenceDoesNotReplaySelectedContainers(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Name: "Actor19", Job: "15", Level: 1}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:10": {ItemID: 700, Count: 1, RawEntry: []byte{0x70}},
},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19"}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "post-hud-container-refresh",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
selectedUserInfoRefreshSent: true,
selectedItemListRefreshSent: true,
selectedRentalWalletStateSent: true,
}
if err := service.sendRuntimeSceneReadySequence(session, "test"); err != nil {
t.Fatalf("sendRuntimeSceneReadySequence error = %v", err)
}
itemListCount := 0
for stream := connection.write.Bytes(); len(stream) > 0; {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
if packet.Header.MsgID == uint16(dnfenum.CmdPacketLeaveParty) && packet.Header.Classification == 0 && len(packet.Body) != 0 {
itemListCount++
}
stream = rest
}
if itemListCount != 0 {
t.Fatalf("post-scene item-list refreshes=%d want=0; actor-bound pre-mode1 stage already owns the container bootstrap", itemListCount)
}
}
func TestSendAlignedUpperResponsesSendsSkillInitAckBeforeCurrentSkillInfo(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "0",
Level: 90,
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 90},
Trees: map[int]dnfrepo.SkillTreeState{
1: {
Skills: map[int64]dnfrepo.SkillState{46: {Level: 1, Enabled: true}},
Points: dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 90},
},
},
Layouts: map[int]dnfrepo.SkillLayout{0: {0: 46}, 1: {150: 46}},
}); err != nil {
t.Fatal(err)
}
catalog, err := buildSkillCatalogFromSource(ctx, initialEquipmentMemSource{
"skill/skilllist.lst": "0 `job0.lst`\n",
"skill/job0.lst": "46 `job0/initial.skl`\n",
"skill/job0/initial.skl": "[skill type]\n`active`\n",
})
if err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
skillCatalog: catalog,
initialSPTable: map[int]int{1: 100},
initialTPTable: map[int]int{50: 10},
}
session := &gameSession{
conn: connection,
connID: "aligned-skill-reset-post-action",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
result := alignedcmd.Result{
Operation: "skill_init",
UpperResponses: []alignedcmd.UpperResponse{{
MsgID: uint16(dnfenum.CmdPacketSkillInit),
Body: []byte{1, 0, 1},
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}},
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedActorSkills},
}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error = %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketSkillInit) || ack.Header.Classification != dnfproto.DefaultChannelClassification || !bytes.Equal(ack.Body, []byte{0, 0, 0, 1, 0, 1}) {
t.Fatalf("skill init ack header=%+v body=%x", ack.Header, ack.Body)
}
refresh, trailing := splitGameServerUpperPacket(t, rest)
if refresh.Header.MsgID != currentSkillInfoMsgID || refresh.Header.Classification != 0 {
t.Fatalf("skill refresh header=%+v body=%x", refresh.Header, refresh.Body)
}
if len(refresh.Body) < 3 || !bytes.Equal(refresh.Body[:3], []byte{0, 0, 0}) {
t.Fatalf("skill refresh codec prefix=%x", refresh.Body)
}
if slots := currentSkillInfoFirstTreeSlots(t, refresh.Body[3:]); len(slots) != 1 || slots[0] != 46 {
t.Fatalf("skill refresh slots=%v, want slot0=46", slots)
}
if len(trailing) != 0 {
t.Fatalf("unexpected trailing bytes: %x", trailing)
}
}
func TestAccountCargoAutoStackSendsAckWithRealLandingSlotThenAccountCargoSnapshot(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:account-auto-stack",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "8",
},
}); err != nil {
t.Fatal(err)
}
stack := func(count int64) dnfrepo.ItemStack {
return dnfrepo.ItemStack{
ItemID: 3227,
Count: count,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": "stackable/material/test_3227.stk",
"stackable_type": "[material]",
"stack_limit": "10",
},
}
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:66": stack(3),
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:account-auto-stack",
Slots: map[string]dnfrepo.ItemStack{
"12:6": stack(2),
},
}); err != nil {
t.Fatal(err)
}
body := make([]byte, 28)
body[0] = 12
binary.LittleEndian.PutUint16(body[1:3], 6)
binary.LittleEndian.PutUint32(body[7:11], 2)
body[11] = 0
binary.LittleEndian.PutUint16(body[12:14], 70)
result, routed, err := gameAlignedRegistry.Route(ctx, alignedcmd.Request{
Command: byte(dnfenum.GameCmdCommand),
CommandKnown: true,
Opcode: uint16(dnfenum.CmdPacketMoveItemspace),
Body: body,
AccountID: "dnf:account-auto-stack",
SelectedCharacterID: 19,
Repositories: repositories,
})
if err != nil || !routed {
t.Fatalf("route account cargo move routed=%t err=%v", routed, err)
}
if !result.Handled || !result.ResponseAllowed {
t.Fatalf("aligned result=%+v", result)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:account-auto-stack",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "account-cargo-auto-stack", selectedCharacterID: 19}
if err := service.sendAlignedUpperResponses(session, result); err != nil {
t.Fatalf("sendAlignedUpperResponses error=%v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketMoveItemspace) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
len(ack.Body) < 14 || binary.LittleEndian.Uint32(ack.Body[7:11]) != 2 {
t.Fatalf("move ACK header=%+v body=%x", ack.Header, ack.Body)
}
// 真实落点是背包里已有的那一堆 0:66(客户端拖过去的是空格 0:70);ACK 必须报 66。
if got := binary.LittleEndian.Uint16(ack.Body[12:14]); got != 66 {
t.Fatalf("move ACK destination slot=%d, want the real landing slot 66; body=%x", got, ack.Body)
}
// 角色容器不再补整 list 快照(那会把 list-0 的金币钱包行一起下发),
// 所以 ACK 之后直接是金库列表快照。
accountCargo, trailing := splitCurrentSceneItemListPacket(t, rest)
if len(accountCargo.Body) < 9 || accountCargo.Body[0] != 12 ||
binary.LittleEndian.Uint16(accountCargo.Body[7:9]) != 0 {
t.Fatalf("account cargo body=%x", accountCargo.Body)
}
if len(trailing) != 0 {
t.Fatalf("unexpected trailing packets=%x", trailing)
}
}