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,853 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"math/bits"
"testing"
"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"
)
func TestChannelReconnectProbeBypassesNativeDecodeAndRosterGuardsIt(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecNative
probe, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCharacterRoster),
make([]byte, currentChannelReconnectProbeSize),
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, probe); err != nil {
t.Fatalf("handle native op2 reconnect probe: %v", err)
}
if !session.channelReconnect {
t.Fatal("native op2/31 was decoded or dropped instead of arming channel reconnect")
}
rosterService, rosterSession, _ := newChannelReconnectTest(t)
rosterFrame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgGetUserInfo),
nil,
2,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := rosterService.handleGameUpper(rosterSession, rosterFrame); err != nil {
t.Fatalf("handle op8 roster request: %v", err)
}
if !rosterSession.rosterRequested {
t.Fatal("op8 did not mark the role roster as requested")
}
if err := rosterService.handleGameUpper(rosterSession, probe); err != nil {
t.Fatalf("handle roster-guarded op2 reconnect probe: %v", err)
}
if rosterSession.channelReconnect {
t.Fatal("op2/31 armed reconnect after the role roster was requested")
}
}
func TestCurrentOp2ReconnectProbeDoesNotOverwriteNativePartyPeerPort(t *testing.T) {
service := &Service{}
session := &gameSession{
conn: &bufferConn{},
connID: "party-peer-registration",
selectedCharacterID: 29,
}
service.bindGameSessionCharacter(session, 29)
registerPartyPeerEndpointForTest(session, 7, 5063)
body := make([]byte, currentChannelReconnectProbeSize)
body[0] = 5
copy(body[1:5], []byte{192, 168, 5, 7})
copy(body[5:9], []byte{192, 168, 5, 7})
// These bytes produced the live false endpoint 39077 when op2 was
// incorrectly interpreted as SET_UDP_IP_PORT.
binary.BigEndian.PutUint16(body[9:11], 39077)
binary.LittleEndian.PutUint32(body[11:15], 1472)
binary.LittleEndian.PutUint32(body[15:19], 12)
copy(body[19:], []byte("4c0f3e70667f"))
frame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCharacterRoster),
body,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, frame); err != nil {
t.Fatal(err)
}
if got := currentPartyPeerPort(session); got != 5063 {
t.Fatalf("op2 reconnect probe overwrote native peer port=%d want=5063", got)
}
if !session.channelReconnect {
t.Fatal("op2 reconnect probe no longer armed reconnect state")
}
}
func TestLegacyGetUserInfoMarksRosterAndPreventsFalseChannelReconnect(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
// Current live op8 has a three-byte body and is classified by the shared
// stream splitter as legacy. It still represents an ordinary roster login.
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.GameTypeGetUserInfo),
[]byte{0xff, 0xff, 0x02},
); err != nil {
t.Fatalf("handle legacy op8 roster request: %v", err)
}
if !session.rosterRequested {
t.Fatal("legacy op8 did not mark the role roster as requested")
}
session.conn.(*bufferConn).write.Reset()
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
frame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgSelectCharacter),
request,
2,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, frame); err != nil {
t.Fatalf("handle roster-owned op4: %v", err)
}
if session.channelReconnect {
t.Fatalf(
"ordinary roster op4 entered reconnect lifecycle route=%t",
session.channelReconnect,
)
}
assertNoChannelReconnectResidentNotice(
t,
splitAllCurrentGameServerUpperPackets(t, session.conn.(*bufferConn).write.Bytes()),
)
}
func TestChannelReconnectOp4RunsImmediateTargetTownRouteWithoutChangingPersistedLocation(t *testing.T) {
service, session, repositories := newChannelReconnectTest(t)
session.channel.ID = 253
session.channel.Name = "ch.253"
session.channel.NoticeName = "ch.253"
session.channel.Port = 10253
session.residentChannel = session.channel
session.connectionTownActorOwnerChannel = 253
session.townActorOwnerChannel = 253
before, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load pre-reconnect character found=%t err=%v", found, err)
}
locationBefore := currentReconnectLocationSnapshot(before)
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
frame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgSelectCharacter),
request,
2,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, frame); err != nil {
t.Fatalf("handle channel reconnect op4: %v", err)
}
packets := splitAllCurrentGameServerUpperPackets(t, session.conn.(*bufferConn).write.Bytes())
assertImmediateChannelReconnectOrder(t, packets, 253)
if session.channelReconnect || session.sceneBootstrapTailDeferred || !session.sceneBootstrapTailSent {
t.Fatalf(
"completed reconnect flags route=%t deferred=%t sent=%t",
session.channelReconnect,
session.sceneBootstrapTailDeferred,
session.sceneBootstrapTailSent,
)
}
after, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load post-reconnect character found=%t err=%v", found, err)
}
if got := currentReconnectLocationSnapshot(after); got != locationBefore {
t.Fatalf("channel reconnect changed durable location: before=%+v after=%+v", locationBefore, got)
}
}
func TestChannelReconnectSendsOneSkillSnapshotBeforeOp24(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
connection := &bufferConn{}
session.conn = connection
session.selectedCharacterID = 29
session.channelReconnect = true
if err := service.sendCurrentChannelReconnectTownEntry(session); err != nil {
t.Fatalf("channel reconnect: %v", err)
}
packets := splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes())
skillCount := 0
skillPreloadIndex := -1
skillIndex := -1
fullMode1Index := -1
transitionIndex := -1
var skillPreloadBody []byte
lastActorStateIndex := -1
for index, packet := range packets {
if packet.Header.MsgID == currentSkillInfoMsgID {
skillCount++
switch skillCount {
case 1:
skillPreloadIndex = index
skillPreloadBody = append([]byte(nil), packet.Body...)
case 2:
skillIndex = index
// The full equipment-bearing mode1 is the actor-state packet
// written between the preload and the authoritative op19.
fullMode1Index = lastActorStateIndex
if !bytes.Equal(packet.Body, skillPreloadBody) {
t.Fatalf("reconnect skill preload=%x final=%x, want identical selected-character projections", skillPreloadBody, packet.Body)
}
}
}
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
lastActorStateIndex = index
}
if packetLooksLikeInitialTownTransition(packet) {
transitionIndex = index
}
}
// The weapon-validation preload precedes the full equipment-bearing mode1;
// the authoritative op19 follows it and both stay before op24.
if skillCount != 2 || skillPreloadIndex < 0 || skillIndex < 0 || transitionIndex < 0 ||
fullMode1Index < 0 || skillPreloadIndex >= fullMode1Index || skillIndex <= fullMode1Index ||
skillIndex >= transitionIndex {
t.Fatalf("reconnect skill count=%d preload=%d final=%d full_mode1=%d transition=%d",
skillCount, skillPreloadIndex, skillIndex, fullMode1Index, transitionIndex)
}
if session.initialTownRouteStage != currentInitialTownRoutePlayerStateSent ||
session.initialTownSkillInfoPrepared || !session.initialTownSkillInfoSent || session.channelReconnect {
t.Fatalf(
"completed reconnect retry state stage=%d prepared=%t sent=%t reconnect=%t",
session.initialTownRouteStage,
session.initialTownSkillInfoPrepared,
session.initialTownSkillInfoSent,
session.channelReconnect,
)
}
}
func TestChannelReconnectPersistedTownResolverDoesNotUnlockNeedQuestArea(t *testing.T) {
service, session, repositories := newChannelReconnectTest(t)
character, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load reconnect character found=%t err=%v", found, err)
}
character.Stats["town_id"] = 38
character.Stats["area_id"] = 3
character.Stats["pos_x"] = 571
character.Stats["pos_y"] = 315
character.Stats["direction"] = 4
character.Stats["area_state"] = 6
questWrites := &countingChannelReconnectQuestRepository{QuestRepository: repositories.Quest}
repositories.Quest = questWrites
service.repositoryProvider = func() (dnfrepo.Group, bool) { return repositories, true }
_, townID, areaID, row, mapPath, err := service.currentChannelReconnectTownTransition(
context.Background(),
session,
29,
character,
)
if err != nil {
t.Fatal(err)
}
if townID != 38 || areaID != 3 || row.Value1 != 571 || row.Value2 != 315 ||
row.Value3 != 4 || row.Value4 != 6 || mapPath != "Elvengard_hendon.map" {
t.Fatalf("read-only reconnect location town=%d area=%d row=%+v map=%q", townID, areaID, row, mapPath)
}
if questWrites.saveCount != 0 {
t.Fatalf("channel reconnect invoked persisted-area need-quest mutator %d time(s)", questWrites.saveCount)
}
if questRecord, found, err := repositories.Quest.Load(context.Background(), "29"); err != nil || found {
t.Fatalf("channel reconnect created quest state for need-quest area found=%t err=%v record=%+v", found, err, questRecord)
}
}
func TestChannelReconnectOp1276IsAckOnlyAndEndpointProbesAreIgnored(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
session.selectedCharacterID = 29
session.channelReconnect = true
session.sceneBootstrapTailDeferred = true
connection := session.conn.(*bufferConn)
barrier, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCheckUserConnection),
nil,
3,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, barrier); err != nil {
t.Fatalf("handle reconnect op1276: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
!bytes.Equal(ack.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) ||
len(rest) != 0 {
t.Fatalf("reconnect op1276 emitted more than its 13-byte ACK: header=%+v body=%x rest=%x", ack.Header, ack.Body, rest)
}
if !session.sceneBootstrapTailDeferred {
t.Fatal("reconnect op1276 ran the ordinary deferred scene tail")
}
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecNative
for index, bodyLen := range []int{
currentChannelReconnectDisplayProbeSize,
reference90CNChannelReconnectDisplayProbeBodySize,
} {
connection.write.Reset()
probe, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgGameEndpoint),
make([]byte, bodyLen),
uint16(4+index),
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, probe); err != nil {
t.Fatalf("handle stale op1/%d probe: %v", bodyLen, err)
}
if connection.write.Len() != 0 {
t.Fatalf("stale op1/%d probe emitted bytes=%x", bodyLen, connection.write.Bytes())
}
}
}
func TestChannelReconnectExactLifecycleRunsOnOp4AndNeverWaitsForGameEndpoint(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecNative
connection := session.conn.(*bufferConn)
probe, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCharacterRoster),
make([]byte, currentChannelReconnectProbeSize),
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, probe); err != nil {
t.Fatal(err)
}
if !session.channelReconnect {
t.Fatal("native op2/31 did not arm channel reconnect")
}
// The generated op4 fixture is plaintext. The native op2/31 and stale
// op1/590/598 edges are exercised at their real wire lengths.
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecPlain
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
selectFrame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgSelectCharacter),
request,
2,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, selectFrame); err != nil {
t.Fatal(err)
}
assertImmediateChannelReconnectOrder(
t,
splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes()),
byte(session.channel.ID),
)
if session.channelReconnect || !session.sceneBootstrapTailSent {
t.Fatalf(
"op4 did not finish reconnect route=%t tail_sent=%t",
session.channelReconnect,
session.sceneBootstrapTailSent,
)
}
if session.townActorOwnerChannel != byte(session.channel.ID) {
t.Fatalf(
"op4 reconnect owner reset to %d, want CHANNELINFO context %d",
session.townActorOwnerChannel,
session.channel.ID,
)
}
connection.write.Reset()
heartbeat, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgCheckUserConnection),
nil,
3,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, heartbeat); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
!bytes.Equal(ack.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) ||
len(rest) != 0 {
t.Fatalf("op1276 lifecycle ACK=%+v body=%x rest=%x", ack.Header, ack.Body, rest)
}
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecNative
for index, bodyLen := range []int{
currentChannelReconnectDisplayProbeSize,
reference90CNChannelReconnectDisplayProbeBodySize,
} {
connection.write.Reset()
endpointProbe, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgGameEndpoint),
make([]byte, bodyLen),
uint16(4+index),
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, endpointProbe); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("post-reconnect op1/%d emitted bytes=%x", bodyLen, connection.write.Bytes())
}
}
}
func TestCurrentClientChannelSwitchStreamRoutesOp4AndIgnoresEndpointProbes(t *testing.T) {
service, session, _ := newChannelReconnectTest(t)
connection := session.conn.(*bufferConn)
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecPlain
request := make([]byte, 2)
binary.LittleEndian.PutUint16(request, 29)
selectFrame, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgSelectCharacter),
request,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
stream := append([]byte(nil), selectFrame...)
for index, bodyLen := range []int{
observed90CNShortEndpointBodySize,
currentChannelReconnectDisplayProbeSize,
currentChannelReconnectDisplayProbeSize + current90CNEndpointShortVariantSize,
reference90CNChannelReconnectDisplayProbeBodySize,
} {
probe, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgGameEndpoint),
current90CNEndpointWireTestBody(bodyLen),
uint16(2+index),
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
stream = append(stream, probe...)
}
packets, remaining, skipped, err := dnfproto.SplitLatestGameStream(stream, 4096)
if err != nil {
t.Fatal(err)
}
if skipped != 0 || len(remaining) != 0 || len(packets) != 5 {
t.Fatalf("current channel switch split packets=%d skipped=%d remaining=%x", len(packets), skipped, remaining)
}
for index, packet := range packets {
if packet.Kind != dnfproto.LatestGameStreamUpper {
t.Fatalf("current channel switch packet[%d] kind=%d want upper", index, packet.Kind)
}
}
if err := service.handleGameStreamPacket(session, packets[0]); err != nil {
t.Fatal(err)
}
assertImmediateChannelReconnectOrder(
t,
splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes()),
byte(session.channel.ID),
)
if session.channelReconnect {
t.Fatalf(
"op4 reconnect state route=%t",
session.channelReconnect,
)
}
service.options.gameUpperClientBodyCodec = gameUpperClientBodyCodecNative
for index := 1; index < len(packets); index++ {
connection.write.Reset()
if err := service.handleGameStreamPacket(session, packets[index]); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("stale endpoint stream packet[%d] emitted bytes=%x", index, connection.write.Bytes())
}
}
}
func current90CNEndpointWireTestBody(bodyLen int) []byte {
body := make([]byte, bodyLen)
binary.LittleEndian.PutUint32(body[0:4], 1)
body[4] = 0x30
body[5] = 0x20
copy(body[9:41], []byte("de509f65e9ccaae621cb7278fc2b8e6c"))
binary.LittleEndian.PutUint32(body[42:46], 8)
copy(body[54:62], []byte{0, 2, 0, 0, 1, 1, 0, 0x1c})
return body
}
type currentReconnectLocation struct {
townID int64
areaID int64
positionX int64
positionY int64
direction int64
areaState int64
}
type countingChannelReconnectQuestRepository struct {
dnfrepo.QuestRepository
saveCount int
}
func (r *countingChannelReconnectQuestRepository) Save(ctx context.Context, record dnfrepo.QuestRecord) error {
r.saveCount++
return r.QuestRepository.Save(ctx, record)
}
func currentReconnectLocationSnapshot(character dnfrepo.CharacterRecord) currentReconnectLocation {
return currentReconnectLocation{
townID: character.Stats["town_id"],
areaID: character.Stats["area_id"],
positionX: character.Stats["pos_x"],
positionY: character.Stats["pos_y"],
direction: character.Stats["direction"],
areaState: character.Stats["area_state"],
}
}
func newChannelReconnectTest(t *testing.T) (*Service, *gameSession, dnfrepo.Group) {
t.Helper()
service, session, repositories := newTownMoveTest(t)
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: defaultAccountPrefix + "1",
Metadata: make(map[string]string),
}); err != nil {
t.Fatal(err)
}
skillCatalog, err := buildSkillCatalogFromSource(context.Background(), 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)
}
service.initialSkillsByJob = map[byte][]initialSkillEntry{0: {{SkillID: 46, Level: 1}}}
service.initialSPTable = map[int]int{90: 3710}
service.initialTPTable = map[int]int{90: 41}
service.skillCatalog = skillCatalog
service.options.accountPrefix = defaultAccountPrefix
service.options.channelAdvertiseID = 0
service.options.serverIP = "127.0.0.1"
service.adventureGroupTable = loadAdventureGroupTestTables(t)
service.questCatalog = buildQuestListTestCatalog(t)
character, found, err := repositories.Character.Load(context.Background(), "29")
if err != nil || !found {
t.Fatalf("load reconnect character found=%t err=%v", found, err)
}
character.Name = "channel-reconnect"
character.Job = "0"
character.Level = 90
character.Stats["town_id"] = 39
character.Stats["area_id"] = 0
character.Stats["pos_x"] = 777
character.Stats["pos_y"] = 333
character.Stats["direction"] = 2
character.Stats["area_state"] = 7
character.Stats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
if err := repositories.Character.Save(context.Background(), character); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "29",
Entries: map[string]dnfrepo.EquipmentEntry{
"11": {
SlotIndex: 11,
ItemID: 900001,
RawEntry: buildInitialEquipmentRawEntry(11, 900001, 27),
},
},
}); err != nil {
t.Fatal(err)
}
session.conn = &bufferConn{}
session.channel = channelcatalog.Channel{
ServerID: 1,
ID: 17,
Type: 1,
Name: "ch.17",
NoticeName: "ch.17",
Port: 10017,
}
session.residentChannel = session.channel
session.connectionTownActorOwnerChannel = byte(session.channel.ID)
session.townActorOwnerChannel = byte(session.channel.ID)
session.currentChannelResidentNoticeSent = true
session.gameEndpointSuccessSent = true
session.selectedCharacterID = 0
session.townSceneReadyCharacterID = 0
session.townPositionSnapshot = currentTownPositionSnapshot{}
session.initialTownRouteCharacterID = 0
session.initialTownRouteStage = currentInitialTownRouteIdle
session.rosterRequested = false
session.channelReconnect = false
session.sceneBootstrapTailDeferred = false
session.sceneBootstrapTailSent = false
return service, session, repositories
}
func splitAllCurrentGameServerUpperPackets(t *testing.T, stream []byte) []dnfproto.ChannelPacket {
t.Helper()
packets := make([]dnfproto.ChannelPacket, 0)
for len(stream) > 0 {
packet, rest := splitCurrentGameServerUpperPacketAuto(t, stream)
packets = append(packets, packet)
stream = rest
}
return packets
}
func assertNoChannelReconnectResidentNotice(t *testing.T, packets []dnfproto.ChannelPacket) {
t.Helper()
for index, packet := range packets {
if packet.Header.MsgID == uint16(dnfenum.UpperMsgGameEndpoint) {
t.Fatalf(
"channel reconnect packet[%d] emitted forbidden endpoint op1 class=%d body=%x",
index,
packet.Header.Classification,
packet.Body,
)
}
}
}
func assertImmediateChannelReconnectOrder(t *testing.T, packets []dnfproto.ChannelPacket, target byte) {
t.Helper()
sceneOwner := target
assertNoChannelReconnectResidentNotice(t, packets)
indexes := map[string]int{
"story": -1,
"mode0": -1,
"light_mode1": -1,
"op800": -1,
"full_mode1": -1,
"first_op21": -1,
"first_op574": -1,
"op124": -1,
"op9": -1,
"op120": -1,
"op22": -1,
"op23": -1,
"op24": -1,
"guild_noti": -1,
"last_op21": -1,
"last_op574": -1,
}
op21Count := 0
op574Count := 0
mode1Count := 0
op800Count := 0
op124Count := 0
op9Count := 0
op120Count := 0
for index, packet := range packets {
switch packet.Header.MsgID {
case currentStoryDigestLastLevelMsgID:
indexes["story"] = index
case uint16(dnfenum.CmdPacketSetUDPIPPort):
if len(packet.Body) < 5 {
continue
}
switch packet.Body[0] {
case 0:
if packet.Body[3] != 0 || packet.Body[4] != sceneOwner {
t.Fatalf("channel reconnect mode0 owner=%x want 00/%02x", packet.Body[:5], sceneOwner)
}
if indexes["mode0"] < 0 {
indexes["mode0"] = index
}
case 1:
mode1Count++
if packet.Body[3] != 0 || packet.Body[4] != sceneOwner {
t.Fatalf("channel reconnect mode1 owner=%x want 00/%02x", packet.Body[:5], sceneOwner)
}
switch mode1Count {
case 1:
if len(packet.Body) != currentMode1BaseWireSize {
t.Fatalf("channel reconnect light mode1 len=%d want=%d body=%x", len(packet.Body), currentMode1BaseWireSize, packet.Body)
}
indexes["light_mode1"] = index
case 2:
if len(packet.Body) <= currentMode1CreateRowsOffset+5 ||
packet.Body[currentMode1CreateCountOffset] != 1 ||
binary.LittleEndian.Uint32(packet.Body[currentMode1CreateRowsOffset+1:currentMode1CreateRowsOffset+5]) != 900001 {
t.Fatalf("channel reconnect full mode1 missing equipment create row body=%x", packet.Body)
}
indexes["full_mode1"] = index
}
}
case currentTownActorSceneSnapshotMsgID:
op800Count++
if indexes["op800"] < 0 {
indexes["op800"] = index
}
case currentAcceptableQuestListMsgID:
op21Count++
if indexes["first_op21"] < 0 {
indexes["first_op21"] = index
}
indexes["last_op21"] = index
case currentActiveQuestSnapshotMsgID:
op574Count++
if indexes["first_op574"] < 0 {
indexes["first_op574"] = index
}
indexes["last_op574"] = index
case uint16(dnfenum.CmdPacketReportClientSpec):
op124Count++
if len(packet.Body) != 0 {
t.Fatalf("channel reconnect op124 body=%x", packet.Body)
}
indexes["op124"] = index
case uint16(dnfenum.CmdPacketRecoverStamina):
op9Count++
assertCurrentPartylessTownOp9Body(t, packet.Body, sceneOwner)
indexes["op9"] = index
case uint16(dnfenum.CmdPacketRequestBlacklist):
op120Count++
indexes["op120"] = index
case currentTownUserPositionNotificationMsgID:
indexes["op22"] = index
case currentTownUserAreaNotificationMsgID:
indexes["op23"] = index
case currentSceneTransitionMsgID:
if packetLooksLikeInitialTownTransition(packet) {
indexes["op24"] = index
}
case currentGuildMembershipNotificationMsgID:
if packet.Header.Classification != 0 ||
!bytes.Equal(packet.Body, buildCurrentGuildMembershipNotificationBody(nil)) {
t.Fatalf("channel reconnect guild notification header=%+v body=%x", packet.Header, packet.Body)
}
indexes["guild_noti"] = index
}
}
if mode1Count != 2 {
t.Fatalf("channel reconnect mode1 count=%d want light binding plus full equipment state", mode1Count)
}
if op800Count != 1 || op124Count != 1 || op9Count != 1 || op120Count != 1 {
t.Fatalf(
"channel reconnect display packet counts op800=%d op124=%d op9=%d op120=%d want=1/1/1/1",
op800Count,
op124Count,
op9Count,
op120Count,
)
}
if op21Count != 2 || op574Count != 2 {
t.Fatalf("channel reconnect quest snapshot counts op21=%d op574=%d want=2/2 indexes=%+v", op21Count, op574Count, indexes)
}
wantOrder := []string{
"story",
"mode0",
"light_mode1",
"op800",
"op22",
"op23",
"full_mode1",
"first_op21",
"first_op574",
"op124",
"op9",
"op120",
"op24",
"guild_noti",
"last_op21",
"last_op574",
}
for index, name := range wantOrder {
if indexes[name] < 0 {
t.Fatalf("channel reconnect packet %s missing: %+v", name, indexes)
}
if index > 0 && indexes[wantOrder[index-1]] >= indexes[name] {
t.Fatalf("channel reconnect order invalid: %+v", indexes)
}
}
}
// assertCurrentChannelNoticePacket remains shared by the cold-login ordering
// tests. Reconnect tests must instead use assertNoChannelReconnectResidentNotice.
func assertCurrentChannelNoticePacket(
t *testing.T,
packet dnfproto.ChannelPacket,
wantServerID uint64,
wantChannelID uint64,
) {
t.Helper()
if packet.Header.Classification != 0 ||
packet.Header.MsgID != uint16(dnfenum.UpperMsgGameEndpoint) {
t.Fatalf("current channel notice header=%+v", packet.Header)
}
plain := make([]byte, len(packet.Body))
for index, value := range packet.Body {
plain[index] = bits.RotateLeft8(value, 6) ^ 0xb5
}
if len(plain) < 4 || int(binary.LittleEndian.Uint32(plain[:4])) != len(plain)-4 {
t.Fatalf("current channel notice protobuf length body=%x plain=%x", packet.Body, plain)
}
fields, _ := consumeChannelInfoProto(t, plain[4:])
if values := fields[7]; len(values) != 1 || values[0] != wantServerID {
t.Fatalf("current channel notice field7=%v want=[%d] plain=%x", values, wantServerID, plain)
}
if values := fields[8]; len(values) != 1 || values[0] != wantChannelID {
t.Fatalf("current channel notice field8=%v want=[%d] plain=%x", values, wantChannelID, plain)
}
}