服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
761 lines
30 KiB
Go
761 lines
30 KiB
Go
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())
|
||
}
|
||
})
|
||
}
|
||
}
|