Files
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

761 lines
30 KiB
Go
Raw Permalink 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"
"testing"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
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 TestBuildCurrentAdventureInfoBodyProjectsShopAndGrowthRuntimeState(t *testing.T) {
projection := adventuregroup.Projection{
Runtime: adventuregroup.RuntimeState{
ShopPoints: [adventuregroup.ShopPointTypeCount]uint32{7, 11, 13},
GrowthExperience: 123456,
Purchases: map[string]uint32{
"0:10304122": 2,
"1:10303899": 3,
},
},
}
body := buildCurrentAdventureInfoBodyWithState(
1,
adventuregroup.Summary{},
nil,
"",
0,
projection,
)
raw := body[18 : 18+currentAdventureInfoRawLength]
// 8 字节条目 = {数量, 当前窗口进度},且**按位置**对应页签。
// 数量决定"X / 250 个"(现场取证:分类号写在第 0 个 dword 时荣耀结晶被显示成 1);
// 进度决定"获得1个结晶"进度条(现场取证:往第 0 条第 2 个 dword 写 1,500,000,
// 商店里正好显示 10%)。
for index, want := range projection.Runtime.ShopPoints {
offset := currentAdventureInfoShopPointOffset + index*currentAdventureInfoShopPointEntrySize
if got := binary.LittleEndian.Uint32(raw[offset : offset+4]); got != want {
t.Fatalf("shop points[%d]=%d want=%d", index, got, want)
}
wantProgress := currentAdventureInfoShopPointProgress(index, projection)
if got := binary.LittleEndian.Uint32(raw[offset+4 : offset+8]); got != wantProgress {
t.Fatalf("shop progress[%d]=%d want=%d", index, got, wantProgress)
}
// 没有经验窗口的分类必须是 0,绝不能回落成"分类号"那种伪进度。
if index >= 1 && index <= 2 && wantProgress != 0 {
t.Fatalf("shop progress[%d]=%d want 0", index, wantProgress)
}
}
first := raw[currentAdventureInfoPurchaseOffset : currentAdventureInfoPurchaseOffset+currentAdventureInfoPurchaseEntrySize]
second := raw[currentAdventureInfoPurchaseOffset+currentAdventureInfoPurchaseEntrySize : currentAdventureInfoPurchaseOffset+2*currentAdventureInfoPurchaseEntrySize]
if binary.LittleEndian.Uint32(first[:4]) != 10304122 || binary.LittleEndian.Uint32(first[4:]) != 2 ||
binary.LittleEndian.Uint32(second[:4]) != 10303899 || binary.LittleEndian.Uint32(second[4:]) != 3 {
t.Fatalf("purchase projection first=%x second=%x", first, second)
}
if got := binary.LittleEndian.Uint64(raw[currentAdventureInfoGrowthCapsuleOffset : currentAdventureInfoGrowthCapsuleOffset+8]); got != 123456 {
t.Fatalf("growth=%d", got)
}
}
// 现场取证(2026-09-23):成长胶囊页签"可获得的经验值"一直是 0、领取按钮不亮,
// 因为客户端读的 raw+7416 我们从来没写过。反汇编确认
// 0x1DE1F30 = `mov eax,[ecx+0x310]; ret`,而 [0x310] 就是解析 raw+7416 存进去的
// 字段;0x13F0FA0 又把它当成 `for (i = 0; i < count; i++)` 的循环上限,
// 所以发出去的必须是**次数**(一格 10% = 1 次 = 1,000,000 点经验值)。
func TestBuildCurrentAdventureInfoBodyWritesCapsuleClaimCount(t *testing.T) {
const (
step = uint32(10) // 一格 10% 里有几档(PVF: max exp / need minimum exp = 10)
)
cases := []struct {
name string
count uint32
wantCount uint32
}{
{name: "没攒够一档", count: 0, wantCount: 0},
{name: "可以领一次", count: 1, wantCount: 1},
{name: "一格", count: step, wantCount: step},
{name: "十格上限", count: 100, wantCount: 100},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
projection := adventuregroup.Projection{CapsuleClaimCount: testCase.count}
body := buildCurrentAdventureInfoBodyWithState(1, adventuregroup.Summary{}, nil, "", 0, projection)
raw := body[18 : 18+currentAdventureInfoRawLength]
got := binary.LittleEndian.Uint32(
raw[currentAdventureInfoCapsuleClaimCountOffset : currentAdventureInfoCapsuleClaimCountOffset+4],
)
if got != testCase.wantCount {
t.Fatalf("capsule claim count = %d, want %d", got, testCase.wantCount)
}
})
}
}
// 没有 PVF 时必须是 0,不能算出乱数。
func TestCurrentAdventureInfoCapsuleClaimCountDefaultsZero(t *testing.T) {
if got := currentAdventureInfoCapsuleClaimCount(adventuregroup.Projection{}); got != 0 {
t.Fatalf("missing table claim count = %d, want 0", got)
}
}
func TestBuildCurrentAdventureInfoBodyMatchesCurrentExeLayout(t *testing.T) {
const (
characterID = uint16(0x1234)
totalPoint = uint64(0x0102030405060708)
)
characters := []dnfrepo.CharacterRecord{{
CharacterID: "20",
Name: "冒险家",
Job: "11",
Level: 90,
Stats: map[string]int64{
"grow_type": 0x22,
"honor_expert_level": 7,
"server_group_id": 2,
},
}}
body := buildCurrentAdventureInfoBody(characterID, adventuregroup.Summary{TotalPoint: totalPoint, ManageLevel: 4}, characters)
if len(body) != 7442 {
t.Fatalf("body length = %d, want 7442", len(body))
}
if got := binary.LittleEndian.Uint16(body[0:2]); got != characterID {
t.Fatalf("character id = %#x, want %#x", got, characterID)
}
for index, value := range body[2:14] {
if value != 0 {
t.Fatalf("neutral top-level byte %d = %#x", index+2, value)
}
}
if got := binary.LittleEndian.Uint32(body[14:18]); got != currentAdventureInfoRawLength {
t.Fatalf("raw length = %d, want %d", got, currentAdventureInfoRawLength)
}
raw := body[18 : 18+currentAdventureInfoRawLength]
if got := binary.LittleEndian.Uint32(raw[0:4]); got != 4 {
t.Fatalf("manage level = %d, want 4", got)
}
// 报文单位是 1/10 点原始经验:raw+8 落的是 TotalPoint/10(见
// currentAdventureWirePointScale),不是账本原值。
if got, want := binary.LittleEndian.Uint64(raw[8:16]), totalPoint/10; got != want {
t.Fatalf("current point = %#x, want %#x (账本 %#x ÷ 10)", got, want, totalPoint)
}
if got := binary.LittleEndian.Uint16(raw[currentAdventureInfoCharacterCountOffset : currentAdventureInfoCharacterCountOffset+2]); got != 1 {
t.Fatalf("total character count = %d, want 1", got)
}
entry := raw[currentAdventureInfoRosterOffset : currentAdventureInfoRosterOffset+currentAdventureInfoRosterEntrySize]
if entry[0] != 11 || entry[1] != 2 || entry[currentAdventureInfoRosterRegionOffset] != 2 {
t.Fatalf(
"real roster job/grow/region = %d/%d/%d",
entry[0],
entry[1],
entry[currentAdventureInfoRosterRegionOffset],
)
}
if got := binary.LittleEndian.Uint32(
entry[currentAdventureInfoRosterCardLevelOffset : currentAdventureInfoRosterCardLevelOffset+4],
); got != 90 {
t.Fatalf("collection-card level = %d, want 90", got)
}
if got := binary.LittleEndian.Uint32(entry[currentAdventureInfoRosterHonorOffset:]); got != 7 {
t.Fatalf("real roster HonorExpert level = %d, want 7", got)
}
wantName := rosterRawNameBytes(characters[0])
if !bytes.Equal(entry[12:12+len(wantName)], wantName) {
t.Fatalf("real roster name = %x, want %x", entry[12:12+len(wantName)], wantName)
}
for index := 0; index < currentAdventureInfoTripleCount; index++ {
offset := currentAdventureInfoTripleOffset + index*currentAdventureInfoTripleSize
if !bytes.Equal(raw[offset:offset+3], []byte{byte(index), 0, 0}) {
t.Fatalf("profession row %d = %x", index, raw[offset:offset+3])
}
}
if got := binary.LittleEndian.Uint64(raw[currentAdventureInfoGrowthCapsuleOffset : currentAdventureInfoGrowthCapsuleOffset+8]); got != 0 {
t.Fatalf("unowned growth-capsule progress = %#x, want 0", got)
}
if got := binary.LittleEndian.Uint32(
raw[currentAdventureInfoCapsuleClaimCountOffset : currentAdventureInfoCapsuleClaimCountOffset+4],
); got != 0 {
t.Fatalf("unowned capsule claim count = %#x, want 0", got)
}
if got := binary.LittleEndian.Uint32(body[len(body)-4:]); got != 0 {
t.Fatalf("display name length = %d, want 0", got)
}
}
func TestBuildCurrentAdventureInfoBodyWritesOwnedActivityFields(t *testing.T) {
projection := adventuregroup.Projection{ConsecutiveLoginDays: 24}
projection.ContentCounts[adventuregroup.ContentTypeRecommendedDungeon] = 7
body := buildCurrentAdventureInfoBodyWithState(
20,
adventuregroup.Summary{TotalPoint: 23310, ManageLevel: 4},
nil,
"group",
20260716,
projection,
)
raw := body[18 : 18+currentAdventureInfoRawLength]
if got := binary.LittleEndian.Uint32(
raw[currentAdventureInfoLoginDaysOffset : currentAdventureInfoLoginDaysOffset+4],
); got != 24 {
t.Fatalf("consecutive login days=%d, want 24", got)
}
for index := 0; index < currentAdventureInfoTripleCount; index++ {
offset := currentAdventureInfoTripleOffset + index*currentAdventureInfoTripleSize
// Native row: type:u8 first, then the profession count.
if raw[offset+currentAdventureInfoTripleTypeOffset] != byte(index) {
t.Fatalf("profession %d wire type=%d", index, raw[offset+currentAdventureInfoTripleTypeOffset])
}
if got := binary.LittleEndian.Uint16(
raw[offset+currentAdventureInfoTripleCountOffset : offset+currentAdventureInfoTripleCountOffset+2],
); got != projection.ProfessionCounts[index] {
t.Fatalf("profession %d count=%d, want %d", index, got, projection.ProfessionCounts[index])
}
}
}
func TestBuildCurrentAdventureInfoBodyWritesHighestHonorExpertSummary(t *testing.T) {
characters := []dnfrepo.CharacterRecord{
{
CharacterID: "10",
Job: "1",
Stats: map[string]int64{
"grow_type": 0x21,
currentHonorExpertLevelStatKey: 3,
currentHonorExpertProgressExperienceStatKey: 90,
},
},
{
CharacterID: "20",
Job: "11",
Stats: map[string]int64{
"grow_type": 0x22,
currentHonorExpertLevelStatKey: 7,
currentHonorExpertProgressExperienceStatKey: 120,
},
},
{
CharacterID: "30",
Job: "2",
Stats: map[string]int64{
"grow_type": 0x23,
currentHonorExpertLevelStatKey: 7,
currentHonorExpertProgressExperienceStatKey: 121,
},
},
}
body := buildCurrentAdventureInfoBody(20, adventuregroup.Summary{}, characters)
raw := body[18 : 18+currentAdventureInfoRawLength]
if got := raw[currentAdventureInfoHighestHonorJobOffset]; got != 2 {
t.Fatalf("highest HonorExpert job=%d, want 2", got)
}
if got := raw[currentAdventureInfoHighestHonorGrowOffset]; got != 3 {
t.Fatalf("highest HonorExpert grow=%d, want 3", got)
}
if got := binary.LittleEndian.Uint32(
raw[currentAdventureInfoHighestHonorLevelOffset : currentAdventureInfoHighestHonorLevelOffset+4],
); got != 7 {
t.Fatalf("highest HonorExpert level=%d, want 7", got)
}
}
func TestWriteCurrentAdventureHighestHonorSummaryRejectsInvalidState(t *testing.T) {
raw := make([]byte, currentAdventureInfoRawLength)
writeCurrentAdventureHighestHonorSummary(raw, []dnfrepo.CharacterRecord{
{
CharacterID: "10",
Job: "invalid",
Stats: map[string]int64{
"grow_type": 1,
currentHonorExpertLevelStatKey: 8,
currentHonorExpertProgressExperienceStatKey: 1,
},
},
{
CharacterID: "20",
Job: "1",
Stats: map[string]int64{
"grow_type": 1,
currentHonorExpertLevelStatKey: -1,
currentHonorExpertProgressExperienceStatKey: 1,
},
},
})
if got := raw[currentAdventureInfoHighestHonorJobOffset : currentAdventureInfoHighestHonorLevelOffset+4]; !bytes.Equal(got, make([]byte, len(got))) {
t.Fatalf("invalid highest HonorExpert state projected bytes=%x", got)
}
}
func TestBuildCurrentAdventureActorRefreshBodyMatchesCurrentExeLayout(t *testing.T) {
const (
currentObjectKey = uint16(0x1234)
totalPoint = uint64(0x0102030405060708)
)
body := buildCurrentAdventureActorRefreshBody(
currentObjectKey,
adventuregroup.Summary{TotalPoint: totalPoint, ManageLevel: 4},
adventuregroup.Projection{},
)
if len(body) != 26 {
t.Fatalf("body length = %d, want 26", len(body))
}
if got := binary.LittleEndian.Uint16(body[0:2]); got != currentObjectKey {
t.Fatalf("current object key = %#x, want %#x", got, currentObjectKey)
}
if got := binary.LittleEndian.Uint32(body[2:6]); got != currentAdventureActorRefreshRawLength {
t.Fatalf("raw state length = %d, want %d", got, currentAdventureActorRefreshRawLength)
}
raw := body[6 : 6+currentAdventureActorRefreshRawLength]
if got := binary.LittleEndian.Uint32(raw[0:4]); got != 4 {
t.Fatalf("manage level = %d, want 4", got)
}
if got := binary.LittleEndian.Uint32(raw[4:8]); got != 0 {
t.Fatalf("reserved state = %#x, want 0", got)
}
// 同上:actor refresh 的 raw[8:16] 也是 1/10 单位。
if got, want := binary.LittleEndian.Uint64(raw[8:16]), totalPoint/10; got != want {
t.Fatalf("current point = %#x, want %#x (账本 %#x ÷ 10)", got, want, totalPoint)
}
if !bytes.Equal(body[22:26], make([]byte, 4)) {
t.Fatalf("actor refresh flags = %x, want zero", body[22:26])
}
}
func TestBuildCurrentAdventureInfoBodyWritesPersistedCreationDate(t *testing.T) {
body := buildCurrentAdventureInfoBodyWithIdentity(
0x1234,
adventuregroup.Summary{TotalPoint: 23310, ManageLevel: 4},
nil,
"group",
20260716,
)
if got := binary.LittleEndian.Uint32(body[2:6]); got != 20260716 {
t.Fatalf("adventure-group created date = %d, want 20260716", got)
}
if got := binary.LittleEndian.Uint32(body[14:18]); got != currentAdventureInfoRawLength {
t.Fatalf("raw length shifted by creation date = %d, want %d", got, currentAdventureInfoRawLength)
}
}
func TestCurrentAdventureInfoRosterNameKeepsFixedFieldNULTerminated(t *testing.T) {
characters := []dnfrepo.CharacterRecord{{
CharacterID: "20",
Name: "一二三四五六七八九十甲乙丙丁戊",
Job: "11",
Level: 90,
Stats: map[string]int64{"grow_type": 0x21},
}}
body := buildCurrentAdventureInfoBody(20, adventuregroup.Summary{TotalPoint: 23310, ManageLevel: 4}, characters)
raw := body[18 : 18+currentAdventureInfoRawLength]
entry := raw[currentAdventureInfoRosterOffset : currentAdventureInfoRosterOffset+currentAdventureInfoRosterEntrySize]
name := entry[currentAdventureInfoRosterNameOffset : currentAdventureInfoRosterNameOffset+currentAdventureInfoRosterNameSize]
if name[len(name)-1] != 0 {
t.Fatalf("fixed roster name is not NUL terminated: %x", name)
}
if got := bytes.IndexByte(name, 0); got != 28 {
t.Fatalf("GB18030 roster name terminator offset = %d, want 28: %x", got, name)
}
}
func TestUpperGetUserInfoDefersSingleRememberedSelectorAdventureInfoUntilHiddenProbe(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:1",
State: "active",
RepresentAccountName: "group",
Metadata: map[string]string{
adventureGroupCreatedDateMetadataKey: "2026-07-16",
currentSelectorAdventureInfoSlotMetadata: "1",
},
}); err != nil {
t.Fatal(err)
}
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "19", AccountID: "dnf:1", Slot: 0, Name: "one", Job: "0", Level: 90, Stats: map[string]int64{"grow_type": 0x21}},
{CharacterID: "20", AccountID: "dnf:1", Slot: 1, Name: "two", Job: "1", Level: 90, Stats: map[string]int64{"grow_type": 0x21}},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
adventureGroupTable: loadAdventureGroupTestTables(t),
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{
conn: connection,
connID: "game-adventure-info-push-test",
channel: channelcatalog.Channel{ID: 19},
}
if err := service.sendUpperGetUserInfoBootstrap(session); err != nil {
t.Fatal(err)
}
roster, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if roster.Header.Classification != 0 || roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) ||
len(roster.Body) == 0 || roster.Body[0] != 2 {
t.Fatalf("first packet is not upper mode2 roster: header=%+v body_prefix=%x", roster.Header, roster.Body[:minInt(len(roster.Body), 4)])
}
if len(rest) != 0 {
t.Fatalf("GET_USERINFO emitted %d unsafe trailing bytes", len(rest))
}
if session.pendingCharacterRosterBootstrap {
t.Fatal("GET_USERINFO must not defer roster behind hidden-character probe")
}
if !session.selectorAdventureInfoPending || session.selectorAdventureInfoSlot != 1 {
t.Fatalf("selector adventure pending=%t slot=%d, want true/1", session.selectorAdventureInfoPending, session.selectorAdventureInfoSlot)
}
connection.write.Reset()
hiddenFrame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCharacViewHiddenInfo),
nil,
7,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, hiddenFrame); err != nil {
t.Fatal(err)
}
hiddenAck, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if hiddenAck.Header.MsgID != uint16(dnfenum.UpperMsgRebirthHardcoreCharac) ||
hiddenAck.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(hiddenAck.Body, []byte{1}) {
t.Fatalf("hidden-info ACK header=%+v body=%x", hiddenAck.Header, hiddenAck.Body)
}
selectorPacket, rest := splitLongHengGameServerUpperPacket(t, rest)
if selectorPacket.Header.Classification != 0 || selectorPacket.Header.MsgID != currentAdventureInfoPushMsgID {
t.Fatalf("selector adventure header=%+v", selectorPacket.Header)
}
if len(selectorPacket.Body) < 18+currentAdventureInfoRawLength {
t.Fatalf("selector adventure body length=%d", len(selectorPacket.Body))
}
if got := binary.LittleEndian.Uint16(selectorPacket.Body[0:2]); got != 1 {
t.Fatalf("selector adventure key=%d, want remembered slot 1", got)
}
if got := binary.LittleEndian.Uint32(selectorPacket.Body[2:6]); got != 20260716 {
t.Fatalf("selector adventure created date=%d", got)
}
raw := selectorPacket.Body[18 : 18+currentAdventureInfoRawLength]
if got := binary.LittleEndian.Uint16(raw[currentAdventureInfoCharacterCountOffset : currentAdventureInfoCharacterCountOffset+2]); got != 2 {
t.Fatalf("selector adventure roster count=%d", got)
}
if len(rest) != 0 {
t.Fatalf("hidden probe emitted %d unexpected trailing bytes", len(rest))
}
if session.selectorAdventureInfoPending {
t.Fatal("selector adventure push must be one-shot")
}
connection.write.Reset()
if err := service.handleGameUpper(session, hiddenFrame); err != nil {
t.Fatal(err)
}
_, rest = splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 {
t.Fatalf("repeated hidden probe replayed selector adventure bytes: %x", rest)
}
}
func TestPersistCurrentSelectorAdventureInfoSlotPreservesAccountState(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:1",
State: "active",
HonorExp: 77,
RepresentAccountName: "group",
Metadata: map[string]string{
adventureGroupCreatedDateMetadataKey: "2026-07-16",
"account_cera": "123",
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:1"},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
service.persistCurrentSelectorAdventureInfoSlot(ctx, &gameSession{}, 4)
account, found, err := repositories.Account.Load(ctx, "dnf:1")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
if account.Metadata[currentSelectorAdventureInfoSlotMetadata] != "4" {
t.Fatalf("remembered selector slot=%q, want 4", account.Metadata[currentSelectorAdventureInfoSlotMetadata])
}
if account.Metadata["account_cera"] != "123" || account.HonorExp != 77 ||
account.RepresentAccountName != "group" {
t.Fatalf("unrelated account state changed: %+v", account)
}
}
func TestHandleCurrentRequestAdventureInfoSendsRealAccountSummary(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:1",
State: "active",
Metadata: map[string]string{adventureGroupCreatedDateMetadataKey: "2026-07-16"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "20",
AccountID: "dnf:1",
Slot: 0,
Level: 2,
Stats: map[string]int64{
adventuregroup.RecommendedDungeonClearStatKey: 3,
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
adventureGroupTable: loadAdventureGroupTestTables(t),
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{
conn: connection,
connID: "game-adventure-info-test",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 20,
}
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestAdventureInfo),
make([]byte, currentAdventureInfoRequestWireLength),
); err != nil {
t.Fatal(err)
}
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 {
t.Fatalf("trailing packet bytes = %d", len(rest))
}
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestAdventureInfo) {
t.Fatalf("header = %+v", packet.Header)
}
if len(packet.Body) != 7443 || packet.Body[0] != 1 {
t.Fatalf("success body length/prefix = %d/%x", len(packet.Body), packet.Body[:1])
}
payload := packet.Body[1:]
if got := binary.LittleEndian.Uint16(payload[0:2]); got != 20 {
t.Fatalf("response character id = %d, want 20", got)
}
if got := binary.LittleEndian.Uint32(payload[2:6]); got != 20260716 {
t.Fatalf("response adventure created date = %d, want 20260716", got)
}
raw := payload[18 : 18+currentAdventureInfoRawLength]
// 真实账号 1 级名册点数是 30;报文按客户端单位下发,即 30 ÷ 10 = 3。
if got := binary.LittleEndian.Uint64(raw[currentAdventureInfoCurrentPointOffset : currentAdventureInfoCurrentPointOffset+8]); got != 3 {
t.Fatalf("real account total point = %d, want 3 (名册 30 点 ÷ 10)", got)
}
if got := binary.LittleEndian.Uint32(raw[currentAdventureInfoManageLevelOffset : currentAdventureInfoManageLevelOffset+4]); got != 1 {
t.Fatalf("real account manage level = %d, want 1", got)
}
if got := binary.LittleEndian.Uint32(raw[currentAdventureInfoLoginDaysOffset : currentAdventureInfoLoginDaysOffset+4]); got != 1 {
t.Fatalf("real account consecutive login days = %d, want 1", got)
}
recommendedOffset := currentAdventureInfoContentCountOffsets[adventuregroup.ContentTypeRecommendedDungeon]
if got := binary.LittleEndian.Uint32(raw[recommendedOffset : recommendedOffset+4]); got != 3 {
t.Fatalf("real account recommended dungeon clears = %d, want 3", got)
}
if got := binary.LittleEndian.Uint64(raw[currentAdventureInfoGrowthCapsuleOffset : currentAdventureInfoGrowthCapsuleOffset+8]); got != 0 {
t.Fatalf("growth-capsule progress = %d, want 0", got)
}
}
func TestHandleCurrentRequestAdventureInfoAcceptsSceneIconRequest(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:1",
State: "active",
Metadata: map[string]string{adventureGroupCreatedDateMetadataKey: "2026-07-16"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Slot: 0,
Level: 90,
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
adventureGroupTable: loadAdventureGroupTestTables(t),
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{
conn: connection,
connID: "game-adventure-info-scene-icon-test",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
request := make([]byte, currentAdventureInfoSceneRequestLength)
binary.LittleEndian.PutUint16(request, currentSceneActorObjectKey(session.selectedCharacterID))
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestAdventureInfo),
request,
); err != nil {
t.Fatal(err)
}
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 {
t.Fatalf("trailing packet bytes = %d", len(rest))
}
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestAdventureInfo) {
t.Fatalf("header = %+v", packet.Header)
}
if len(packet.Body) <= 1 || packet.Body[0] != 1 {
t.Fatalf("success body length/prefix = %d/%x", len(packet.Body), packet.Body[:minInt(len(packet.Body), 1)])
}
payload := packet.Body[1:]
if got := binary.LittleEndian.Uint16(payload[0:2]); got != 19 {
t.Fatalf("response character id = %d, want 19", got)
}
}
func TestHandleCurrentRequestAdventureInfoAcceptsVisiblePeerSceneIcon(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
for _, account := range []dnfrepo.AccountRecord{
{AccountID: "dnf:1", State: "active", Metadata: map[string]string{adventureGroupCreatedDateMetadataKey: "2026-07-16"}},
{AccountID: "dnf:2", State: "active", RepresentAccountName: "peer-group", Metadata: map[string]string{adventureGroupCreatedDateMetadataKey: "2026-07-17"}},
} {
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
}
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Name: "source", Level: 90},
{CharacterID: "5", AccountID: "dnf:2", Name: "target", Level: 2},
} {
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
}
sourceConn := &bufferConn{}
source := &gameSession{conn: sourceConn, accountID: "dnf:1", selectedCharacterID: 1}
target := &gameSession{conn: &bufferConn{}, accountID: "dnf:2", selectedCharacterID: 5}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
adventureGroupTable: loadAdventureGroupTestTables(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.bindGameSessionCharacter(source, 1)
service.bindGameSessionCharacter(target, 5)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1, TownID: 1, AreaID: 2, Session: source})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 5, TownID: 1, AreaID: 2, Session: target})
if err := service.handleGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestAdventureInfo),
[]byte{5, 0},
); err != nil {
t.Fatal(err)
}
packet, rest := splitLongHengGameServerUpperPacket(t, sourceConn.write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestAdventureInfo) || len(rest) != 0 ||
len(packet.Body) < 7443 || packet.Body[0] != 1 {
t.Fatalf("peer adventure header=%+v body_len=%d trailing=%d", packet.Header, len(packet.Body), len(rest))
}
payload := packet.Body[1:]
if binary.LittleEndian.Uint16(payload[:2]) != 5 || binary.LittleEndian.Uint32(payload[2:6]) != 20260717 {
t.Fatalf("peer adventure identity/date=%x", payload[:6])
}
}
func TestHandleCurrentRequestAdventureInfoRejectsInvalidOwnershipAndShape(t *testing.T) {
tests := []struct {
name string
selectedID uint16
body []byte
bodyLength int
accountID string
}{
{name: "no selected character", bodyLength: currentAdventureInfoRequestWireLength, accountID: "dnf:1"},
{name: "wrong protected body length", selectedID: 20, bodyLength: 3, accountID: "dnf:1"},
{name: "wrong scene icon character", selectedID: 20, body: []byte{19, 0}, accountID: "dnf:1"},
{name: "owner mismatch", selectedID: 20, bodyLength: currentAdventureInfoRequestWireLength, accountID: "dnf:2"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{AccountID: "dnf:1"}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{CharacterID: "20", AccountID: test.accountID, Level: 1}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
adventureGroupTable: loadAdventureGroupTestTables(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, selectedCharacterID: test.selectedID}
body := test.body
if body == nil {
body = make([]byte, test.bodyLength)
}
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestAdventureInfo),
body,
); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("rejected request wrote %d bytes", connection.write.Len())
}
})
}
}