DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,760 @@
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())
}
})
}
}