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

2649 lines
106 KiB
Go

// 本文件验证 DNF game 端已对齐命令的桥接回包顺序。
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"reflect"
"strings"
"sync"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
type orderedPartyWrite struct {
connection string
data []byte
}
type orderedPartyWriteLog struct {
mu sync.Mutex
writes []orderedPartyWrite
}
type orderedPartyConn struct {
*bufferConn
name string
log *orderedPartyWriteLog
}
type contextBoundSkillRulesCharacterRepository struct {
dnfrepo.CharacterRepository
}
func (repository *contextBoundSkillRulesCharacterRepository) Load(ctx context.Context, characterID string) (dnfrepo.CharacterRecord, bool, error) {
<-ctx.Done()
return dnfrepo.CharacterRecord{}, false, ctx.Err()
}
func (connection *orderedPartyConn) Write(data []byte) (int, error) {
connection.log.mu.Lock()
connection.log.writes = append(connection.log.writes, orderedPartyWrite{
connection: connection.name,
data: append([]byte(nil), data...),
})
connection.log.mu.Unlock()
return connection.bufferConn.Write(data)
}
func TestGameAlignedRegistryRoutesRegisteredDomains(t *testing.T) {
tests := []struct {
name string
opcode dnfenum.CmdPacket
domain dnfenum.AlignedDomain
responseAllowed bool
}{
{name: "inventory", opcode: dnfenum.CmdPacketDeleteItem, domain: dnfenum.AlignedDomainInventory, responseAllowed: true},
{name: "pet", opcode: dnfenum.CmdPacketHatchCreatureEgg, domain: dnfenum.AlignedDomainPet},
{name: "mail", opcode: dnfenum.CmdPacketMailboxOpen, domain: dnfenum.AlignedDomainMail, responseAllowed: true},
{name: "quest", opcode: dnfenum.CmdPacketAcceptQuest, domain: dnfenum.AlignedDomainQuest},
{name: "skill", opcode: dnfenum.CmdPacketBuySkill, domain: dnfenum.AlignedDomainSkill, responseAllowed: true},
{name: "cargo", opcode: dnfenum.CmdPacketCreateAccountCargo, domain: dnfenum.AlignedDomainCargo},
{name: "item lock", opcode: dnfenum.CmdPacketRequestItemLock, domain: dnfenum.AlignedDomainItemLock},
{name: "item sort lock", opcode: dnfenum.CmdPacketRequestItemSortLock, domain: dnfenum.AlignedDomainItemLock},
{name: "dungeon", opcode: dnfenum.CmdPacketGetItem, domain: dnfenum.AlignedDomainDungeon},
{name: "avatar title", opcode: dnfenum.CmdPacketTitleBookPut, domain: dnfenum.AlignedDomainAvatarTitle},
{name: "package", opcode: dnfenum.CmdPacketUseRandomboxItem, domain: dnfenum.AlignedDomainPackage},
{name: "package expand", opcode: dnfenum.CmdPacketUseRandomboxItemExpand, domain: dnfenum.AlignedDomainPackage},
{name: "party", opcode: dnfenum.CmdPacketSetPartyInfo, domain: dnfenum.AlignedDomainParty, responseAllowed: true},
{name: "raid", opcode: dnfenum.CmdPacketRaidManagerWork, domain: dnfenum.AlignedDomainRaid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok, err := gameAlignedRegistry.Route(context.Background(), alignedcmd.Request{Opcode: uint16(tt.opcode)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(%d) not classified", tt.opcode)
}
if got.Decision.Domain != tt.domain {
t.Fatalf("domain = %q, want %q", got.Decision.Domain, tt.domain)
}
if got.ResponseAllowed != tt.responseAllowed {
t.Fatalf("responseAllowed = %v, want %v", got.ResponseAllowed, tt.responseAllowed)
}
if strings.Contains(got.Reason, "not registered") {
t.Fatalf("opcode %d was not handled by registered module: %q", tt.opcode, got.Reason)
}
})
}
}
func TestLegacyItemSortLockAndUnlockRoundTrip(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"0:64": {ItemID: 1001, Count: 1, Extra: map[string]string{"equipment_lock_state": "1"}},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
}
connection := &bufferConn{}
session := &gameSession{
conn: connection, connID: "legacy-sort-lock",
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 77,
}
body := []byte{0, 0x40, 0x00}
wantACKBody := []byte{1, 0, 0x40, 0x00}
for index, opcode := range []dnfenum.CmdPacket{
dnfenum.CmdPacketRequestItemSortLock,
dnfenum.CmdPacketRequestItemSortUnlock,
} {
connection.write.Reset()
frame := buildLegacyGamePacketForBridgeTest(byte(dnfenum.GameCmdCommand), uint16(opcode), uint16(index+1), body)
if err := service.handleLegacyGamePacket(session, frame); err != nil {
t.Fatalf("handle legacy opcode %d: %v", opcode, err)
}
ack, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(opcode) || !bytes.Equal(ack.Body, wantACKBody) {
t.Fatalf("opcode %d ACK header=%+v body=%x trailing=%d", opcode, ack.Header, ack.Body, len(trailing))
}
record, found, err := repos.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory after opcode %d: found=%t err=%v", opcode, found, err)
}
wantLocked := opcode == dnfenum.CmdPacketRequestItemSortLock
if got := dnfitemlock.IsSortLocked(record.Slots["0:64"]); got != wantLocked {
t.Fatalf("opcode %d sort locked=%t want=%t", opcode, got, wantLocked)
}
if record.Slots["0:64"].Extra["equipment_lock_state"] != "1" {
t.Fatalf("opcode %d changed ordinary item lock: %+v", opcode, record.Slots["0:64"].Extra)
}
}
}
func TestGameAlignedRegistryCoversAlignedDomains(t *testing.T) {
for _, command := range dnfenum.AlignedCommands() {
if command.Domain == dnfenum.AlignedDomainCharacter {
continue
}
t.Run(dnfenum.CmdPacketName(uint16(command.Opcode)), func(t *testing.T) {
got, ok, err := gameAlignedRegistry.Route(context.Background(), alignedcmd.Request{Opcode: uint16(command.Opcode)})
if err != nil {
t.Fatalf("Route error = %v", err)
}
if !ok {
t.Fatalf("Route(%d) not classified", command.Opcode)
}
if got.Decision.Domain != command.Domain {
t.Fatalf("domain = %q, want %q", got.Decision.Domain, command.Domain)
}
if strings.Contains(got.Reason, "not registered") {
t.Fatalf("aligned opcode %d has no registered module: %q", command.Opcode, got.Reason)
}
})
}
}
func TestAlignedSkillOwnerRulesRequiredForMutationCommands(t *testing.T) {
tests := []struct {
name string
opcode dnfenum.CmdPacket
want bool
}{
{name: "change slot", opcode: dnfenum.CmdPacketChangeSkillslot, want: true},
{name: "buy skill", opcode: dnfenum.CmdPacketBuySkill, want: true},
{name: "skill init", opcode: dnfenum.CmdPacketSkillInit, want: true},
{name: "unrelated command", opcode: dnfenum.CmdPacketMailboxOpen, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := alignedSkillOwnerRulesRequired(tt.opcode); got != tt.want {
t.Fatalf("alignedSkillOwnerRulesRequired(%d) = %t, want %t", tt.opcode, got, tt.want)
}
})
}
}
func TestBuySkillRulesPreflightCancellationReturnsNativeFailure(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
repositories.Character = &contextBoundSkillRulesCharacterRepository{CharacterRepository: repositories.Character}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
connection := &bufferConn{}
requestContext, cancel := context.WithCancel(context.Background())
cancel()
session := &gameSession{
ctx: requestContext,
conn: connection,
channel: channelcatalog.Channel{ID: 121, Type: 1, Name: "ch.121", Port: 10121},
selectedCharacterID: 4,
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketBuySkill),
[]byte{0, 1, 100, 0, 0, 1, 0},
)
if err != nil {
t.Fatalf("handle canceled op29 preflight: %v", err)
}
if !handled {
t.Fatal("canceled op29 preflight was not handled")
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketBuySkill) ||
!bytes.Equal(packet.Body, []byte{0, 0x12}) || len(trailing) != 0 {
t.Fatalf("canceled op29 ACK header=%+v body=%x trailing=%x", packet.Header, packet.Body, trailing)
}
}
func TestHandleAlignedGameCommandSendsMailboxOpenAck(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repos, true
},
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 77,
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketMailboxOpen),
nil,
)
if err != nil {
t.Fatalf("handleAlignedGameCommand error = %v", err)
}
if !handled {
t.Fatalf("MailboxOpen should be handled by aligned module")
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
snapshot, trailing := splitGameServerUpperPacket(t, rest)
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
t.Fatalf("classification = %d, want %d", packet.Header.Classification, dnfproto.DefaultChannelClassification)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketMailboxOpen) {
t.Fatalf("msgID = %d, want %d", packet.Header.MsgID, dnfenum.CmdPacketMailboxOpen)
}
if !bytes.Equal(packet.Body, []byte{0x01, 0x00, 0x00}) {
t.Fatalf("body = % X, want 01 00 00", packet.Body)
}
if snapshot.Header.Classification != 0 || snapshot.Header.MsgID != 0x0061 || !bytes.Equal(snapshot.Body, []byte{0, 0, 0, 0, 0, 0}) {
t.Fatalf("mailbox snapshot header=%+v body=%x, want class0/op97 empty snapshot", snapshot.Header, snapshot.Body)
}
if len(trailing) != 0 {
t.Fatalf("mailbox open trailing bytes = %d, want only op96 ACK and class0/op97 snapshot", len(trailing))
}
}
func TestHandleAlignedGameCommandRepliesToMailboxAccountRoleRequestWithOp718(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
for _, character := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:mail-picker", Name: "sender", Slot: 0},
{CharacterID: "2", AccountID: "dnf:mail-picker", Name: "target", Slot: 1},
{CharacterID: "3", AccountID: "another-account", Name: "unrelated", Slot: 0},
} {
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("save character %s: %v", character.CharacterID, err)
}
}
service := &Service{
options: options{
accountID: "dnf:mail-picker",
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repos, true
},
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 1,
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestServerCharacterList),
[]byte{0},
)
if err != nil {
t.Fatalf("handleAlignedGameCommand error = %v", err)
}
if !handled {
t.Fatalf("mailbox account-role request should be handled by aligned module")
}
packet, trailing := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("mailbox account-role trailing bytes = %d, want 0", len(trailing))
}
if got := packet.Header; got.Classification != 0 || got.MsgID != 718 {
t.Fatalf("mailbox account-role header = %+v, want class0/op718", got)
}
want := []byte{0, 1, 2, 0, 0, 0, 6, 0, 0, 0, 't', 'a', 'r', 'g', 'e', 't', 0}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("mailbox account-role body = % X, want % X", packet.Body, want)
}
}
func TestHandleGameUpperFallbackRoutesUnlistedAccountCargoToOwner(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "dnf:upper-cargo"}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "91",
AccountID: "dnf:upper-cargo",
Stats: map[string]int64{"gold": 500000},
}); err != nil {
t.Fatalf("save character: %v", err)
}
service := &Service{
options: options{
accountID: "dnf:upper-cargo",
gameUpperBodyCodec: gameUpperBodyCodecPlain,
gameUpperClientBodyCodec: gameUpperClientBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repos, true
},
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "game-upper-cargo-fallback",
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 91,
}
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketCreateAccountCargo), nil, 0, dnfproto.DefaultChannelClassification)
if err != nil {
t.Fatalf("build frame: %v", err)
}
if err := service.handleGameUpper(session, frame); err != nil {
t.Fatalf("handleGameUpper create account cargo: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketCreateAccountCargo) ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("create cargo ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
gold, rest := splitGameServerUpperPacket(t, rest)
if gold.Header.Classification != 0 || gold.Header.MsgID != 0x000E || len(gold.Body) != 3+0x77 {
t.Fatalf("gold refresh = class %d msg %d len=%d body=% X", gold.Header.Classification, gold.Header.MsgID, len(gold.Body), gold.Body)
}
if got := binary.LittleEndian.Uint32(gold.Body[9:13]); got != 400000 {
t.Fatalf("gold refresh value = %d, want 400000", got)
}
// 开通/升级金库改变了容量:金库列表之前必须先补一发仓库底板重建(op391)。
pad, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if pad.Header.Classification != 0 || pad.Header.MsgID != uint16(dnfenum.CmdPacketCancelCargoPad) {
t.Fatalf("create cargo must reset the warehouse pad first: header=%+v", pad.Header)
}
accountCargo, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if len(rest) != 0 {
t.Fatalf("unexpected trailing bytes: %d", len(rest))
}
if accountCargo.Header.Classification != 0 ||
accountCargo.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) ||
len(accountCargo.Body) != 9 ||
accountCargo.Body[0] != 12 ||
binary.LittleEndian.Uint16(accountCargo.Body[1:3]) != 1 ||
binary.LittleEndian.Uint32(accountCargo.Body[3:7]) != 0 ||
binary.LittleEndian.Uint16(accountCargo.Body[7:9]) != 0 {
t.Fatalf("account cargo refresh packet = class %d msg %d body=% X", accountCargo.Header.Classification, accountCargo.Header.MsgID, accountCargo.Body)
}
account, ok, err := repos.Account.Load(ctx, "dnf:upper-cargo")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cargo_created"] != "true" || account.Metadata["account_cargo_level"] != "1" {
t.Fatalf("account cargo metadata = %+v", account.Metadata)
}
character, ok, err := repos.Character.Load(ctx, "91")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 400000 {
t.Fatalf("character gold = %d, want 400000", got)
}
}
func TestHandleAlignedGameCommandSendsSingleMemberPartySequence(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 1001,
townSceneReadyCharacterID: 1001,
}
service.bindGameSessionCharacter(session, 1001)
body := []byte{0x00, 0x03, 0x04, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x9c, 0x00}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketSetPartyInfo),
body,
)
if err != nil {
t.Fatalf("handleAlignedGameCommand error = %v", err)
}
if !handled {
t.Fatalf("SetPartyInfo should be handled by aligned module")
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketSetPartyInfo) ||
!bytes.Equal(ack.Body, []byte{0x01}) {
t.Fatalf("ack packet = class %d msg %d body % X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
assertCurrentPartyFrameSelectedSlot(t, rest, 0)
rest = assertRuntimePartySceneRefresh(t, rest, 1)
if len(rest) != 0 {
t.Fatalf("unexpected unsolicited party-directory bytes: %d", len(rest))
}
}
func TestHandleOnlineEntryIntoPartyLinksOnlineSessions(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
writeLog := &orderedPartyWriteLog{}
sourceConn := &orderedPartyConn{bufferConn: &bufferConn{}, name: "source", log: writeLog}
targetConn := &orderedPartyConn{bufferConn: &bufferConn{}, name: "target", log: writeLog}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
source := &gameSession{
conn: sourceConn,
channel: channel,
selectedCharacterID: 1001,
party: partySessionState{state: alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
UserState: 1,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}},
}
target := &gameSession{
conn: targetConn,
channel: channel,
selectedCharacterID: 1002,
}
service.bindGameSessionCharacter(source, 1001)
service.bindGameSessionCharacter(target, 1002)
registerPartyPeerEndpointForTest(source, 3, 5063)
registerPartyPeerEndpointForTest(target, 3, 60359)
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketEntryIntoParty),
[]byte{0xea, 0x03, 0x00, 0x00},
)
if err != nil {
t.Fatalf("handle entry into party: %v", err)
}
if !handled {
t.Fatalf("EntryIntoParty should be handled by online party bridge")
}
if len(source.party.state.Members) != 2 || len(target.party.state.Members) != 2 {
t.Fatalf("member counts source=%d target=%d", len(source.party.state.Members), len(target.party.state.Members))
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketEntryIntoParty) ||
!bytes.Equal(ack.Body, []byte{1, 0xea, 0x03, 0x00, 0x00, 0xe9, 0x03, 0x00, 0x00}) {
t.Fatalf("entry ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
assertCurrentPartyFrameSelectedSlot(t, rest, 0)
rest = assertRuntimePartySnapshot(t, rest, 2)
if len(rest) != 0 {
t.Fatalf("source trailing bytes = %d", len(rest))
}
targetData := targetConn.write.Bytes()
assertCurrentPartyFrameSelectedSlot(t, targetData, 0)
targetRest := assertRuntimePartySnapshot(t, targetData, 2)
if len(targetRest) != 0 {
t.Fatalf("target trailing bytes = %d", len(targetRest))
}
// The two peer tables must be installed before either op9 starts NoPack's
// native peer controller. Per-connection ordering alone is insufficient:
// source op11 -> source op9 -> target op11 drops source's first handshake.
writeLog.mu.Lock()
writes := append([]orderedPartyWrite(nil), writeLog.writes...)
writeLog.mu.Unlock()
peerTablesBeforeFirstPartyFrame := make(map[string]bool, 2)
firstPartyFrameSeen := false
for _, write := range writes {
packet, trailing := splitGameServerUpperPacket(t, write.data)
if len(trailing) != 0 {
t.Fatalf("%s write contains %d trailing bytes", write.connection, len(trailing))
}
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case 0x000b:
if firstPartyFrameSeen {
t.Fatalf("%s peer table arrived after the first party op9", write.connection)
}
peerTablesBeforeFirstPartyFrame[write.connection] = true
case uint16(dnfenum.CmdPacketRecoverStamina):
if !firstPartyFrameSeen {
firstPartyFrameSeen = true
if !peerTablesBeforeFirstPartyFrame["source"] || !peerTablesBeforeFirstPartyFrame["target"] {
t.Fatalf("first party op9 arrived before both op11 tables: %+v", peerTablesBeforeFirstPartyFrame)
}
}
}
}
if !firstPartyFrameSeen {
t.Fatal("party formation did not emit op9")
}
}
func TestSendCurrentPartyFrameProjectionRejectsPartyMissingReceiver(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
receiverConn := &bufferConn{}
receiver := &gameSession{
conn: receiverConn,
selectedCharacterID: 1001,
}
other := &gameSession{
conn: &bufferConn{},
selectedCharacterID: 1002,
}
service.bindGameSessionCharacter(receiver, 1001)
service.bindGameSessionCharacter(other, 1002)
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1002,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1002, UserState: 1},
},
}
if _, err := service.sendCurrentPartyFrameProjection(receiver, state, "test_missing_receiver"); err == nil {
t.Fatal("party projection without the receiving character should fail closed")
}
if receiverConn.write.Len() != 0 {
t.Fatalf("fail-closed party projection wrote %d bytes", receiverConn.write.Len())
}
emptyState := alignedcmd.PartyState{
PartyID: 78,
UserID: 1001,
MaxMembers: 4,
}
if _, err := service.sendCurrentPartyFrameProjection(receiver, emptyState, "test_empty_active_party"); err == nil {
t.Fatal("active party projection without members should fail closed")
}
if receiverConn.write.Len() != 0 {
t.Fatalf("empty active party projection wrote %d bytes", receiverConn.write.Len())
}
}
func TestSendCurrentPartyActorFrameProjectionUpdatesRemoteJoinState(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
receiverConn := &bufferConn{}
receiver := &gameSession{conn: receiverConn, selectedCharacterID: 1001}
actor := &gameSession{conn: &bufferConn{}, selectedCharacterID: 1002}
service.bindGameSessionCharacter(receiver, 1001)
service.bindGameSessionCharacter(actor, 1002)
createdSoloParty := alignedcmd.PartyState{
PartyID: 1002, UserID: 1002, IsLeader: true, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{UserID: 1002, UserState: 1}},
}
if sent, err := service.sendCurrentPartyActorFrameProjection(receiver, actor, createdSoloParty, "test_remote_party_created"); err != nil || !sent {
t.Fatalf("active remote projection sent=%t err=%v", sent, err)
}
packet, rest := splitGameServerUpperPacket(t, receiverConn.write.Bytes())
if len(packet.Body) < 6 || binary.LittleEndian.Uint16(packet.Body[4:6]) != currentSceneActorObjectKey(1002) {
t.Fatalf("remote actor key body=% X", packet.Body)
}
assertCurrentPartyFrameProjection(t, receiverConn.write.Bytes(), 1, true)
if len(rest) != 0 {
t.Fatalf("active remote projection trailing=%d", len(rest))
}
receiverConn.write.Reset()
if sent, err := service.sendCurrentPartyActorFrameProjection(receiver, actor, alignedcmd.PartyState{}, "test_remote_party_cleared"); err != nil || !sent {
t.Fatalf("empty remote projection sent=%t err=%v", sent, err)
}
removed, rest := splitGameServerUpperPacket(t, receiverConn.write.Bytes())
wantRemoved := buildCurrentSceneOp9ActorRemovalBodyInContext(
currentSceneActorObjectKey(1002),
receiver.townActorOwnerChannel,
)
if removed.Header.Classification != 0 || removed.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
!bytes.Equal(removed.Body, wantRemoved) || len(rest) != 0 {
t.Fatalf("empty remote projection class=%d msg=%d body=% X trailing=%d",
removed.Header.Classification, removed.Header.MsgID, removed.Body, len(rest))
}
}
func TestSendCurrentPartyActorFrameProjectionUsesConnectionOwnerAfterLocalActorContextReset(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
receiver := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 121},
selectedCharacterID: 1001,
connectionTownActorOwnerChannel: 121,
townActorOwnerChannel: currentSceneObjectContext,
}
service.bindGameSessionCharacter(receiver, 1001)
state := alignedcmd.PartyState{
PartyID: 1,
UserID: 1001,
IsLeader: true,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{UserID: 1001, UserState: 1}},
}
sent, err := service.sendCurrentPartyFrameProjection(receiver, state, "test_connection_owner_after_local_context_reset")
if err != nil || !sent {
t.Fatalf("party projection sent=%t err=%v", sent, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) {
t.Fatalf("party projection packet msg=%d rest=%d", packet.Header.MsgID, len(rest))
}
if len(packet.Body) < 12 || packet.Body[7] != 121 || packet.Body[10] != 1 || packet.Body[11] != 0 {
t.Fatalf("party aggregate owner header=%x want channel=121 server=1 custom-name=0", packet.Body[:minInt(len(packet.Body), 12)])
}
}
func TestSendRuntimePartySnapshotDefersWholeSequenceForUnboundSession(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
selectedCharacterID: 1001,
}
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1},
},
}
if err := service.sendRuntimePartySnapshot(session, state); err != nil {
t.Fatalf("deferred party snapshot: %v", err)
}
if conn.write.Len() != 0 {
t.Fatalf("deferred party snapshot wrote a partial sequence of %d bytes", conn.write.Len())
}
}
func TestHandleResponsePeerAcceptsOnlinePartyInvite(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
inviterConn := &bufferConn{}
acceptorConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
inviter := &gameSession{
conn: inviterConn,
channel: channel,
selectedCharacterID: 1001,
party: partySessionState{state: alignedcmd.PartyState{
PartyID: 88,
UserID: 1001,
UserState: 1,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}},
}
acceptor := &gameSession{
conn: acceptorConn,
channel: channel,
selectedCharacterID: 1002,
}
service.bindGameSessionCharacter(inviter, 1001)
service.bindGameSessionCharacter(acceptor, 1002)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1001,
TownID: 1,
AreaID: 2,
Session: inviter,
})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1002,
TownID: 1,
AreaID: 2,
Session: acceptor,
})
registerPartyPeerEndpointForTest(inviter, 3, 5063)
registerPartyPeerEndpointForTest(acceptor, 3, 60359)
inviterIdentity, inviterBound := service.boundGameSessionCharacterSnapshot(inviter)
acceptorIdentity, acceptorBound := service.boundGameSessionCharacterSnapshot(acceptor)
if !inviterBound || !acceptorBound || !service.runtimePartyManagerForService().RecordInvite(
acceptorIdentity.character, acceptorIdentity.generation,
inviterIdentity.character, inviterIdentity.generation, 0, 0,
) {
t.Fatal("could not install a current-session party invite")
}
handled, err := service.handleAlignedGameCommand(
acceptor,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketResponsePeer),
[]byte{0xe9, 0x03, 0, 0, 0, 0, 0},
)
if err != nil {
t.Fatalf("handle response peer: %v", err)
}
if !handled {
t.Fatalf("ResponsePeer should be handled by online party bridge")
}
if _, pending := service.runtimePartyManagerForService().ConsumePeerRequest(
acceptorIdentity.character, acceptorIdentity.generation,
inviterIdentity.character, inviterIdentity.generation, 0,
); pending {
t.Fatal("central pending invite was not consumed")
}
ack, rest := splitGameServerUpperPacket(t, acceptorConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketResponsePeer) ||
!bytes.Equal(ack.Body, []byte{1, 0xe9, 0x03, 0}) {
t.Fatalf("response peer ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
// The class1/op11 acceptance ACK already completed the ordinary invite.
// Sending class1/op705 here makes the current client enter its raid flow.
assertCurrentPartyFrameSelectedSlot(t, acceptorConn.write.Bytes(), 0)
rest = assertRuntimePartySnapshot(t, rest, 2)
rest = assertCurrentPartyFrameProjection(t, rest, 2, true)
// The peer projection leaves the frame on a kind-0 op9; the publish closes it with
// the op153 rows again so the client's party HUD keeps its HP/MP values.
rest = assertRuntimePartyRealtimeRows(t, rest, 2)
if len(rest) != 0 {
t.Fatalf("acceptor trailing bytes = %d", len(rest))
}
// The same-version invitation path never sends the generic class0/op8
// peer-action notice to the inviter. Its first post-acceptance packet is the
// native class0/op11 endpoint table, immediately followed by op9 and op153.
inviterRest := inviterConn.write.Bytes()
assertCurrentPartyFrameSelectedSlot(t, inviterRest, 0)
inviterRest = assertRuntimePartySnapshot(t, inviterRest, 2)
inviterRest = assertCurrentPartyFrameProjection(t, inviterRest, 2, true)
inviterRest = assertRuntimePartyRealtimeRows(t, inviterRest, 2)
if len(inviterRest) != 0 {
t.Fatalf("inviter trailing bytes = %d", len(inviterRest))
}
}
func TestPendingPartyInviteRejectsReplacedInviterSession(t *testing.T) {
service := &Service{}
inviterOld := &gameSession{}
acceptor := &gameSession{}
service.bindGameSessionCharacter(inviterOld, 1001)
service.bindGameSessionCharacter(acceptor, 1002)
oldIdentity, oldBound := service.boundGameSessionCharacterSnapshot(inviterOld)
acceptorIdentity, acceptorBound := service.boundGameSessionCharacterSnapshot(acceptor)
if !oldBound || !acceptorBound || !service.runtimePartyManagerForService().RecordInvite(
acceptorIdentity.character, acceptorIdentity.generation,
oldIdentity.character, oldIdentity.generation, 0, 13,
) {
t.Fatal("could not register original invite")
}
// A reconnect keeps the same character ID but creates a new game session.
// An old invitation must not attach the acceptor to that new connection.
inviterReplacement := &gameSession{}
service.bindGameSessionCharacter(inviterReplacement, 1001)
replacementIdentity, replacementBound := service.onlineGameSessionCharacterSnapshot(1001)
if !replacementBound || replacementIdentity.session != inviterReplacement {
t.Fatal("replacement inviter was not installed as the authoritative session")
}
if _, accepted := service.runtimePartyManagerForService().ConsumePeerRequest(
acceptorIdentity.character, acceptorIdentity.generation,
replacementIdentity.character, replacementIdentity.generation, 13,
); accepted {
t.Fatal("invite accepted after inviter session replacement")
}
if !service.runtimePartyManagerForService().RecordInvite(
acceptorIdentity.character, acceptorIdentity.generation,
replacementIdentity.character, replacementIdentity.generation, 0, 13,
) {
t.Fatal("could not replace stale invite with current session")
}
if _, accepted := service.runtimePartyManagerForService().ConsumePeerRequest(
acceptorIdentity.character, acceptorIdentity.generation,
replacementIdentity.character, replacementIdentity.generation, 13,
); !accepted {
t.Fatal("current-session invite was not accepted")
}
}
func TestOnlinePartyCommandIgnoresReplacedSourceSession(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
staleSource := &gameSession{}
target := &gameSession{}
service.bindGameSessionCharacter(staleSource, 1001)
service.bindGameSessionCharacter(target, 1002)
// Replace character 1001 before its old connection's direct-join command
// is processed. The old packet must not form a party with the live target.
replacement := &gameSession{}
service.bindGameSessionCharacter(replacement, 1001)
handled, err := service.handleOnlinePartyCommand(
staleSource,
uint16(dnfenum.CmdPacketEntryIntoParty),
[]byte{0xea, 0x03, 0x00, 0x00},
)
if err != nil || !handled {
t.Fatalf("stale direct join handled=%t err=%v", handled, err)
}
if got := runtimePartyStateSnapshot(target); got.PartyID != 0 || len(got.Members) != 0 {
t.Fatalf("stale direct join changed target party: %+v", got)
}
if got := runtimePartyStateSnapshot(replacement); got.PartyID != 0 || len(got.Members) != 0 {
t.Fatalf("stale direct join changed replacement party: %+v", got)
}
}
func TestHandleRequestPeerAcknowledgesVisibleTownTarget(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
sourceConn := &bufferConn{}
source := &gameSession{
conn: sourceConn,
connID: "peer-source",
selectedCharacterID: 1001,
}
target := &gameSession{
conn: &bufferConn{},
connID: "peer-target",
selectedCharacterID: 1002,
}
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1001,
TownID: 1,
AreaID: 2,
Session: source,
})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1002,
TownID: 1,
AreaID: 2,
Session: target,
})
service.bindGameSessionCharacter(source, 1001)
service.bindGameSessionCharacter(target, 1002)
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestPeer),
[]byte{0xea, 0x03, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
)
if err != nil {
t.Fatalf("handle request peer: %v", err)
}
if !handled {
t.Fatal("RequestPeer should be handled by online peer bridge")
}
packet, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestPeer) ||
!bytes.Equal(packet.Body, []byte{1}) {
t.Fatalf("request peer ack = class %d msg %d body=% X",
packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
partyState, rest := splitGameServerUpperPacket(t, rest)
wantPartyless := buildCurrentSceneOp9ActorRemovalBodyInContext(
currentSceneActorObjectKey(1002),
source.townActorOwnerChannel,
)
if partyState.Header.Classification != 0 ||
partyState.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
!bytes.Equal(partyState.Body, wantPartyless) {
t.Fatalf("request peer partyless target state = class %d msg %d body=% X",
partyState.Header.Classification, partyState.Header.MsgID, partyState.Body)
}
selection, rest := splitGameServerUpperPacket(t, rest)
if selection.Header.Classification != 0 || selection.Header.MsgID != 0x0007 ||
!bytes.Equal(selection.Body, []byte{0xea, 0x03, 15, 0, 0, 0, 0, 0xff, 0xff}) {
t.Fatalf("request peer selection notice = class %d msg %d body=% X",
selection.Header.Classification, selection.Header.MsgID, selection.Body)
}
if len(rest) != 0 {
t.Fatalf("request peer trailing bytes = %d", len(rest))
}
}
func TestHandleRequestPeerRefreshesActiveTargetBeforeOpeningMenu(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
sourceConn := &bufferConn{}
source := &gameSession{conn: sourceConn, connID: "peer-source-active", selectedCharacterID: 1001}
target := &gameSession{conn: &bufferConn{}, connID: "peer-target-active", selectedCharacterID: 1002}
// The target's party has to be a genuine two-member one: a one-member
// aggregate is deliberately projected to the clicking client exactly like a
// partyless character, so that its own menu offers invite instead of a join
// the client can never complete. See
// TestTownPeerSelectionOffersInviteForAOneMemberParty.
teammate := &gameSession{conn: &bufferConn{}, connID: "peer-target-teammate", selectedCharacterID: 1003}
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1001, TownID: 1, AreaID: 2, Session: source})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1002, TownID: 1, AreaID: 2, Session: target})
service.bindGameSessionCharacter(source, 1001)
service.bindGameSessionCharacter(target, 1002)
service.bindGameSessionCharacter(teammate, 1003)
storeRuntimePartyState(target, alignedcmd.PartyState{
PartyID: 1002, UserID: 1002, IsLeader: true, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1002, UserState: 1},
{UserID: 1003, UserState: 1},
},
})
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestPeer),
[]byte{0xea, 0x03, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
)
if err != nil || !handled {
t.Fatalf("handle active target request peer handled=%t err=%v", handled, err)
}
ack, rest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketRequestPeer) ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("active target request ack class=%d msg=%d body=%x", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
rest = assertCurrentPartyFrameProjection(t, rest, 2, true)
selection, rest := splitGameServerUpperPacket(t, rest)
if selection.Header.Classification != 0 || selection.Header.MsgID != 0x0007 ||
!bytes.Equal(selection.Body, []byte{0xea, 0x03, 15, 0, 0, 0, 0, 0, 0}) || len(rest) != 0 {
t.Fatalf("active target selection class=%d msg=%d body=%x trailing=%d",
selection.Header.Classification, selection.Header.MsgID, selection.Body, len(rest))
}
}
func TestHandleRequestPeerDoesNotAcknowledgeTargetInAnotherArea(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
sourceConn := &bufferConn{}
source := &gameSession{
conn: sourceConn,
connID: "peer-source",
selectedCharacterID: 1001,
}
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1001,
TownID: 1,
AreaID: 2,
Session: source,
})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: 1002,
TownID: 1,
AreaID: 3,
Session: &gameSession{selectedCharacterID: 1002},
})
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestPeer),
[]byte{0xea, 0x03, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
)
if err != nil {
t.Fatalf("handle request peer outside area: %v", err)
}
if !handled {
t.Fatal("RequestPeer should be consumed even when the target is outside the area")
}
if sourceConn.write.Len() != 0 {
t.Fatalf("outside-area request wrote %d bytes", sourceConn.write.Len())
}
}
func TestHandleRequestPeerForwardsOnlineAssaultInvite(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
sourceConn := &bufferConn{}
targetConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
source := &gameSession{
conn: sourceConn,
channel: channel,
selectedCharacterID: 1001,
}
target := &gameSession{
conn: targetConn,
channel: channel,
selectedCharacterID: 1002,
}
service.bindGameSessionCharacter(source, 1001)
service.bindGameSessionCharacter(target, 1002)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1001, TownID: 1, AreaID: 2, Session: source})
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1002, TownID: 1, AreaID: 2, Session: target})
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestPeer),
[]byte{0xea, 0x03, 13, 0, 0, 0, 0, 0, 0, 0, 0},
)
if err != nil {
t.Fatalf("handle request peer invite forward: %v", err)
}
if !handled {
t.Fatalf("RequestPeer invite should be handled by online party bridge")
}
sourceAck, sourceRest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if sourceAck.Header.Classification != dnfproto.DefaultChannelClassification ||
sourceAck.Header.MsgID != uint16(dnfenum.CmdPacketRequestPeer) || !bytes.Equal(sourceAck.Body, []byte{1}) {
t.Fatalf("request peer ack = class %d msg %d body=% X trailing=%d", sourceAck.Header.Classification, sourceAck.Header.MsgID, sourceAck.Body, len(sourceRest))
}
wait, trailing := splitGameServerUpperPacket(t, sourceRest)
if len(trailing) != 0 || wait.Header.Classification != 0 || wait.Header.MsgID != 444 || !bytes.Equal(wait.Body, []byte{0xea, 3, 13}) {
t.Fatalf("assault wait dialog = %+v trailing=%x", wait, trailing)
}
sourceIdentity, sourceBound := service.boundGameSessionCharacterSnapshot(source)
targetIdentity, targetBound := service.boundGameSessionCharacterSnapshot(target)
if !sourceBound || !targetBound {
t.Fatal("invite endpoints were not bound")
}
if _, pending := service.runtimePartyManagerForService().ConsumePeerRequest(
targetIdentity.character, targetIdentity.generation,
sourceIdentity.character, sourceIdentity.generation, 13,
); !pending {
t.Fatal("central invite was not recorded")
}
packet, rest := splitGameServerUpperPacket(t, targetConn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("target trailing bytes = %d", len(rest))
}
if packet.Header.Classification != 0 || packet.Header.MsgID != 443 ||
!bytes.Equal(packet.Body, []byte{0xe9, 0x03, 13}) {
t.Fatalf("forwarded invite packet = class %d msg %d body=% X", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
}
func TestHandleResponsePeerRejectsPendingInviteWithoutForwarding(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
inviterConn := &bufferConn{}
acceptorConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
inviter := &gameSession{
conn: inviterConn,
channel: channel,
selectedCharacterID: 1001,
}
acceptor := &gameSession{
conn: acceptorConn,
channel: channel,
selectedCharacterID: 1002,
}
service.bindGameSessionCharacter(inviter, 1001)
service.bindGameSessionCharacter(acceptor, 1002)
inviterIdentity, inviterBound := service.boundGameSessionCharacterSnapshot(inviter)
acceptorIdentity, acceptorBound := service.boundGameSessionCharacterSnapshot(acceptor)
if !inviterBound || !acceptorBound || !service.runtimePartyManagerForService().RecordInvite(
acceptorIdentity.character, acceptorIdentity.generation,
inviterIdentity.character, inviterIdentity.generation, 0, 13,
) {
t.Fatal("could not install a current-session party invite")
}
handled, err := service.handleAlignedGameCommand(
acceptor,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketResponsePeer),
[]byte{0xe9, 0x03, 13, 0, 0, 0, 0},
)
if err != nil {
t.Fatalf("handle response peer reject: %v", err)
}
if !handled {
t.Fatalf("ResponsePeer reject should be handled by online party bridge")
}
if _, pending := service.runtimePartyManagerForService().ConsumePeerRequest(
acceptorIdentity.character, acceptorIdentity.generation,
inviterIdentity.character, inviterIdentity.generation, 13,
); pending {
t.Fatal("central pending invite was not consumed")
}
ack, acceptorRest := splitGameServerUpperPacket(t, acceptorConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != uint16(dnfenum.CmdPacketResponsePeer) ||
!bytes.Equal(ack.Body, []byte{1, 0xe9, 0x03, 13}) || len(acceptorRest) != 0 {
t.Fatalf("reject ack = class %d msg %d body=% X trailing=%d", ack.Header.Classification, ack.Header.MsgID, ack.Body, len(acceptorRest))
}
reject, rest := splitGameServerUpperPacket(t, inviterConn.write.Bytes())
if len(rest) != 0 || reject.Header.Classification != 1 || reject.Header.MsgID != 11 || !bytes.Equal(reject.Body, []byte{0, 0xeb, 13}) {
t.Fatalf("assault decline notification = %+v trailing=%x", reject, rest)
}
}
func TestHandleRequestAndResponsePeerForwardsTradePrompt(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
}
sourceConn := &bufferConn{}
targetConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 42, Name: "ch.42"}
source := &gameSession{conn: sourceConn, channel: channel, selectedCharacterID: 1}
target := &gameSession{conn: targetConn, channel: channel, selectedCharacterID: 5}
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})
requestBody := []byte{5, 0, 1, 0x0a, 0x82, 0x01, 0, 0, 0, 0, 0}
handled, err := service.handleAlignedGameCommand(source, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestPeer), requestBody)
if err != nil || !handled {
t.Fatalf("trade request handled=%t err=%v", handled, err)
}
requestAck, sourceRest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if requestAck.Header.Classification != dnfproto.DefaultChannelClassification ||
requestAck.Header.MsgID != uint16(dnfenum.CmdPacketRequestPeer) || !bytes.Equal(requestAck.Body, []byte{1}) || len(sourceRest) != 0 {
t.Fatalf("trade request ack = class %d msg %d body=% X trailing=%d", requestAck.Header.Classification, requestAck.Header.MsgID, requestAck.Body, len(sourceRest))
}
prompt, targetRest := splitGameServerUpperPacket(t, targetConn.write.Bytes())
if prompt.Header.Classification != 0 || prompt.Header.MsgID != 7 ||
!bytes.Equal(prompt.Body, []byte{1, 0, 1, 0x0a, 0x82, 0x01, 0, 0, 0, 0, 0}) || len(targetRest) != 0 {
t.Fatalf("trade prompt = class %d msg %d body=% X trailing=%d", prompt.Header.Classification, prompt.Header.MsgID, prompt.Body, len(targetRest))
}
// The response below consumes the generation-bound central request. Its ACK
// and forwarded notice prove that this request was recorded without
// destructively reading the pending-invite table here.
sourceConn.write.Reset()
targetConn.write.Reset()
handled, err = service.handleAlignedGameCommand(target, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketResponsePeer), []byte{1, 0, 1, 0, 0, 0, 0})
if err != nil || !handled {
t.Fatalf("trade response handled=%t err=%v", handled, err)
}
responseAck, targetRest := splitGameServerUpperPacket(t, targetConn.write.Bytes())
if responseAck.Header.Classification != dnfproto.DefaultChannelClassification ||
responseAck.Header.MsgID != uint16(dnfenum.CmdPacketResponsePeer) ||
!bytes.Equal(responseAck.Body, []byte{1, 1, 0, 1, 0}) || len(targetRest) != 0 {
t.Fatalf("trade response ack = class %d msg %d body=% X trailing=%d", responseAck.Header.Classification, responseAck.Header.MsgID, responseAck.Body, len(targetRest))
}
notice, sourceRest := splitGameServerUpperPacket(t, sourceConn.write.Bytes())
if notice.Header.Classification != 0 || notice.Header.MsgID != 8 ||
!bytes.Equal(notice.Body, []byte{5, 0, 1, 0, 0, 0, 0}) || len(sourceRest) != 0 {
t.Fatalf("trade response notice = class %d msg %d body=% X trailing=%d", notice.Header.Classification, notice.Header.MsgID, notice.Body, len(sourceRest))
}
}
// assertRuntimePartyMembershipCleared consumes the frame that takes a member's
// own party window away: the op9 kind-3 record removal for its old party id
// followed by an empty 0x0099 row table. It is what the native leave handler
// publishes for the leaver, and what the single-member disband rule publishes
// for both sides.
func assertRuntimePartyMembershipCleared(t *testing.T, data []byte, wantPartyID uint16) []byte {
t.Helper()
frame, rest := splitGameServerUpperPacket(t, data)
if frame.Header.Classification != 0 ||
frame.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
len(frame.Body) != 10 ||
binary.LittleEndian.Uint16(frame.Body[2:4]) != currentSceneOp9StableSceneValue ||
binary.LittleEndian.Uint16(frame.Body[4:6]) != wantPartyID ||
frame.Body[9] != currentSceneOp9ActorRemoveKind {
t.Fatalf("party membership clear = class %d msg %d body=% X, want kind3 removal of party %d",
frame.Header.Classification, frame.Header.MsgID, frame.Body, wantPartyID)
}
realtime, rest := splitGameServerUpperPacket(t, rest)
if realtime.Header.Classification != 0 || realtime.Header.MsgID != 0x0099 ||
len(realtime.Body) == 0 || realtime.Body[0] != 0 {
t.Fatalf("party membership clear realtime = class %d msg %d body=% X, want an empty table",
realtime.Header.Classification, realtime.Header.MsgID, realtime.Body)
}
return rest
}
// assertRuntimePartySoloRowThenCleared consumes the dissolve projection a member
// left behind by a dissolved party receives: the native kind-2 one-member row
// (multiplayer-to-solo confirmation) with its 0x0099 rows, and then the kind-3
// clear that removes the party object itself.
func assertRuntimePartySoloRowThenCleared(t *testing.T, data []byte, wantLeaderSlot byte, wantPartyID uint16) []byte {
t.Helper()
rest := assertRuntimePartyMembershipReduction(t, data, 1, wantLeaderSlot, 1)
return assertRuntimePartyMembershipCleared(t, rest, wantPartyID)
}
func TestHandleOnlineLeavePartyDisbandsTheTwoMemberParty(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
IsLeader: true,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
},
}
leader := &gameSession{conn: leaderConn, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: cloneRuntimePartyState(state)}}
member := &gameSession{conn: memberConn, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: cloneRuntimePartyState(state)}}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketLeaveParty),
nil,
)
if err != nil {
t.Fatalf("handle leave party: %v", err)
}
if !handled {
t.Fatalf("LeaveParty should be handled by online party bridge")
}
if leader.party.state.PartyID != 0 {
t.Fatalf("leader party was not cleared: %+v", leader.party.state)
}
// Operator rule 2026-09-22 「一个人不成队」: the leave dropped the party to a
// single member, so that member is detached as well instead of being left in
// a party window it cannot see or leave.
if member.party.state.PartyID != 0 || len(member.party.state.Members) != 0 {
t.Fatalf("survivor kept a one-member aggregate = %+v", member.party.state)
}
if snapshot, found := service.runtimePartyManagerForService().SnapshotByUser(1002, member.characterGeneration); found {
t.Fatalf("survivor remained in the central party: %+v", snapshot)
}
ack, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("leave ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
rest = assertRuntimePartyMembershipCleared(t, rest, uint16(state.PartyID))
if len(rest) != 0 {
t.Fatalf("leader trailing bytes = %d", len(rest))
}
memberRest := assertRuntimePartySoloRowThenCleared(
t, memberConn.write.Bytes(), 1, uint16(state.PartyID))
if len(memberRest) != 0 {
t.Fatalf("member trailing bytes = %d", len(memberRest))
}
}
func TestHandleOnlineWalkoutPartyMemberDisbandsTheTwoMemberParty(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
IsLeader: true,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
},
}
leader := &gameSession{conn: leaderConn, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: cloneRuntimePartyState(state)}}
member := &gameSession{conn: memberConn, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: cloneRuntimePartyState(state)}}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
// The walkout path publishes the removal under the manager's own party
// generation (snapshot.StateFor), not the id the fixture cached, so pin the
// id the client is actually told about.
managed, managedFound := service.bootstrapManagedRuntimePartyForSession(leader)
if !managedFound || managed.ID == 0 {
t.Fatalf("walkout fixture party was not imported: %+v found=%t", managed, managedFound)
}
managedPartyID := managed.ID
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketWalkoutPartyMember),
[]byte{1},
)
if err != nil {
t.Fatalf("handle walkout party member: %v", err)
}
if !handled {
t.Fatalf("WalkoutPartyMember should be handled by online party bridge")
}
// Kicking the only other member leaves the kicker alone: same single-member
// state, same dissolve.
if leader.party.state.PartyID != 0 || len(leader.party.state.Members) != 0 {
t.Fatalf("leader survivor aggregate = %+v", leader.party.state)
}
if member.party.state.PartyID != 0 {
t.Fatalf("kicked member party was not cleared: %+v", member.party.state)
}
ack, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("walkout ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
rest = assertRuntimePartyWalkoutNotice(t, rest, 1, 0)
rest = assertRuntimePartySoloRowThenCleared(t, rest, 0, managedPartyID)
if len(rest) != 0 {
t.Fatalf("leader trailing bytes = %d", len(rest))
}
memberRest := assertRuntimePartyWalkoutNotice(t, memberConn.write.Bytes(), 1, 0)
memberRest = assertRuntimePartyMembershipCleared(t, memberRest, managedPartyID)
if len(memberRest) != 0 {
t.Fatalf("member trailing bytes = %d", len(memberRest))
}
}
func TestHandleOnlineChangePartyMemberPositionBroadcastsSlots(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
leaderConn := &bufferConn{}
memberAConn := &bufferConn{}
memberBConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
IsLeader: true,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
{UserID: 1003, UserState: 1, HPPercent: 70, MPPercent: 60},
},
}
leader := &gameSession{conn: leaderConn, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: cloneRuntimePartyState(state)}}
memberA := &gameSession{conn: memberAConn, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: cloneRuntimePartyState(state)}}
memberB := &gameSession{conn: memberBConn, channel: channel, selectedCharacterID: 1003, party: partySessionState{state: cloneRuntimePartyState(state)}}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(memberA, 1002)
service.bindGameSessionCharacter(memberB, 1003)
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketChangePartyMemberPosition),
[]byte{1, 3},
)
if err != nil {
t.Fatalf("handle change party member position: %v", err)
}
if !handled {
t.Fatalf("ChangePartyMemberPosition should be handled by online party bridge")
}
for name, session := range map[string]*gameSession{
"leader": leader,
"memberA": memberA,
"memberB": memberB,
} {
if len(session.party.state.Members) != 3 ||
session.party.state.Members[0].UserID != 1002 ||
session.party.state.Members[1].UserID != 1001 ||
session.party.state.Members[2].UserID != 1003 {
t.Fatalf("%s party members = %+v, want 1002,1001,1003", name, session.party.state.Members)
}
}
for name, test := range map[string]struct {
data []byte
leaderSlot byte
}{
"leader": {data: leaderConn.write.Bytes(), leaderSlot: 1},
"memberA": {data: memberAConn.write.Bytes(), leaderSlot: 1},
"memberB": {data: memberBConn.write.Bytes(), leaderSlot: 1},
} {
_, frameData := splitGameServerUpperPacket(t, test.data)
assertCurrentPartyFrameSelectedSlot(t, frameData, test.leaderSlot)
rest := assertRuntimePartyChangePosition(t, test.data, 1, 0)
rest = assertRuntimePartySceneRefresh(t, rest, 3)
if len(rest) != 0 {
t.Fatalf("%s trailing bytes = %d", name, len(rest))
}
}
}
func TestHandleOnlineChangeHostPreservesStableSlotsAndRefreshesLeaderSlot(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
state := alignedcmd.PartyState{
PartyID: 1001,
UserID: 1001,
IsLeader: true,
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
},
}
leader := &gameSession{conn: leaderConn, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: cloneRuntimePartyState(state)}}
member := &gameSession{conn: memberConn, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: cloneRuntimePartyState(state)}}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketChangeHost),
[]byte{1},
)
if err != nil {
t.Fatalf("handle change host: %v", err)
}
if !handled {
t.Fatal("ChangeHost should be handled by online party bridge")
}
for name, session := range map[string]*gameSession{"old_leader": leader, "new_leader": member} {
got := runtimePartyStateSnapshot(session)
members := runtimePartyMembers(got)
if got.UserID != 1002 || got.PartyID != 2 || len(members) != 2 ||
members[0].UserID != 1001 || members[0].Slot != 0 ||
members[1].UserID != 1002 || members[1].Slot != 1 {
t.Fatalf("%s party state=%+v members=%+v", name, got, members)
}
}
if leaderConn.write.Len() == 0 || memberConn.write.Len() == 0 {
t.Fatalf("change host did not rebuild both clients: leader=%d member=%d", leaderConn.write.Len(), memberConn.write.Len())
}
for name, data := range map[string][]byte{
"old_leader": leaderConn.write.Bytes(),
"new_leader": memberConn.write.Bytes(),
} {
var msgIDs []uint16
for len(data) > 0 {
packet, rest := splitGameServerUpperPacket(t, data)
msgIDs = append(msgIDs, packet.Header.MsgID)
data = rest
}
if !reflect.DeepEqual(msgIDs, []uint16{
uint16(dnfenum.CmdPacketRecoverStamina),
0x0099,
}) {
t.Fatalf("%s change-host packets=%v, want stable op9 aggregate then op153 without op11", name, msgIDs)
}
assertCurrentPartyFrameSelectedSlot(t, map[string][]byte{
"old_leader": leaderConn.write.Bytes(),
"new_leader": memberConn.write.Bytes(),
}[name], 1)
}
}
func TestHandleOnlineReserveLeavePartyBroadcastsToMembers(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
sourceConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
state := alignedcmd.PartyState{
PartyID: 77,
UserID: 1001,
IsLeader: true,
NameBytes: []byte("party"),
MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 90, MPPercent: 80},
},
}
source := &gameSession{conn: sourceConn, channel: channel, selectedCharacterID: 1001, party: partySessionState{state: cloneRuntimePartyState(state)}}
member := &gameSession{conn: memberConn, channel: channel, selectedCharacterID: 1002, party: partySessionState{state: cloneRuntimePartyState(state)}}
service.bindGameSessionCharacter(source, 1001)
service.bindGameSessionCharacter(member, 1002)
handled, err := service.handleAlignedGameCommand(
source,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketReserveLeaveParty),
[]byte{1},
)
if err != nil {
t.Fatalf("handle reserve leave party: %v", err)
}
if !handled {
t.Fatalf("ReserveLeaveParty should be handled by online party bridge")
}
for name, data := range map[string][]byte{
"source": sourceConn.write.Bytes(),
"member": memberConn.write.Bytes(),
} {
packet, rest := splitGameServerUpperPacket(t, data)
if len(rest) != 0 {
t.Fatalf("%s trailing bytes = %d", name, len(rest))
}
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketReserveLeaveParty) ||
!bytes.Equal(packet.Body, []byte{1, 1, 0xe9, 0x03}) {
t.Fatalf("%s reserve packet = class %d msg %d body=% X",
name, packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
}
}
func TestHandleOnlineEntryIntoPartyFinishUsesCurrentExeClassZeroBody(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
state := alignedcmd.PartyState{
PartyID: 1,
UserID: 1001,
UserState: 1,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1},
{UserID: 1002, UserState: 1},
},
}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038},
selectedCharacterID: 1001,
party: partySessionState{state: cloneRuntimePartyState(state)},
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketEntryIntoPartyFinish),
nil,
)
if err != nil {
t.Fatalf("handle entry into party finish: %v", err)
}
if !handled {
t.Fatalf("EntryIntoPartyFinish should be handled by online party bridge")
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("entry finish trailing bytes = %d", len(rest))
}
if packet.Header.Classification != 0 ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketEntryIntoPartyFinish) ||
!bytes.Equal(packet.Body, []byte{1, 0}) {
t.Fatalf("entry finish packet = class %d msg %d body=% X",
packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
}
func TestHandleOnlineCreateRaidBuildsRuntimeRefresh(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
session := &gameSession{conn: conn, channel: channel}
service.bindGameSessionCharacter(session, 1001)
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketCreateRaid),
buildRaidInfoRequestForBridgeTest(1, []byte("anton"), 5),
)
if err != nil {
t.Fatalf("handle create raid: %v", err)
}
if !handled {
t.Fatalf("CreateRaid should be handled by online raid bridge")
}
wantRaidKey := uint32(200)<<16 | 1001
state := service.raids[wantRaidKey]
if state == nil || state.LeaderID != 1001 || len(state.Members) != 1 || !bytes.Equal(state.NameBytes, []byte("anton")) {
t.Fatalf("runtime raid state = %+v, want leader 1001 with one member", state)
}
object, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
wantObjectBody, err := raid.BuildRaidObjectMode0Body(runtimeRaidObjectMode0(*state))
if err != nil {
t.Fatalf("build raid object: %v", err)
}
if object.Header.Classification != 0 || object.Header.MsgID != raid.RaidObjectMsgID ||
!bytes.Equal(object.Body, wantObjectBody) {
t.Fatalf("create raid object = class %d msg %d body=% X", object.Header.Classification, object.Header.MsgID, object.Body)
}
buff, rest := splitGameServerUpperPacket(t, rest)
wantBuffBody := mustRuntimeRaidBuffRegistrationSnapshotBody(t, *state)
if buff.Header.Classification != 0 || buff.Header.MsgID != raid.RaidBuffMsgID ||
!bytes.Equal(buff.Body, wantBuffBody) {
t.Fatalf("create raid buff = class %d msg %d body=% X", buff.Header.Classification, buff.Header.MsgID, buff.Body)
}
ack, rest := splitGameServerUpperPacket(t, rest)
wantCreateBody := raid.BuildCreateRaidResultBody(wantRaidKey)
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketCreateRaid) ||
!bytes.Equal(ack.Body, wantCreateBody) {
t.Fatalf("create raid ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
refresh, rest := splitGameServerUpperPacket(t, rest)
wantRefreshBody, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(*state))
if err != nil {
t.Fatalf("build raid member refresh: %v", err)
}
if refresh.Header.Classification != 0 || refresh.Header.MsgID != raid.RaidMemberRefreshMsgID ||
!bytes.Equal(refresh.Body, wantRefreshBody) {
t.Fatalf("create raid member refresh = class %d msg %d body=% X", refresh.Header.Classification, refresh.Header.MsgID, refresh.Body)
}
if len(rest) != 0 {
t.Fatalf("create raid trailing bytes = %d", len(rest))
}
}
// The raid opens with the leader in squad 1 and nobody else assigned: the members it
// invites are 单人 until the leader hands out a 编制 (live 2026-09-17 22:0x, the
// attack-party status window: the leader's row carries 1队, the invited member reads
// 单人). The client reads that byte straight (sub_17B6EF0 stores it at +0x2D) and
// renders 0 as 单人, so the wire has to carry exactly this.
func TestHandleOnlineCreateRaidOpensWithTheLeaderInSquadOne(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
session := &gameSession{conn: conn, channel: channel}
service.bindGameSessionCharacter(session, 1001)
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketCreateRaid),
buildRaidInfoRequestForBridgeTest(1, []byte("anton"), 0),
)
if err != nil || !handled {
t.Fatalf("create raid handled=%t err=%v", handled, err)
}
state := service.raids[uint32(200)<<16|1001]
if state == nil || len(state.Members) != 1 {
t.Fatalf("runtime raid state = %+v, want one member", state)
}
if member := state.Members[0]; member.GroupIndex != runtimeRaidDefaultGroup || member.SlotOrder != 0 {
t.Fatalf("leader's 编制=%d/%d, want squad 1 slot 0", member.GroupIndex, member.SlotOrder)
}
if record := runtimeRaidMemberRecord(*state, state.Members[0]); record.GroupIndex != runtimeRaidDefaultGroup {
t.Fatalf("roster record group=%d, want squad %d", record.GroupIndex, runtimeRaidDefaultGroup)
}
}
// A member the leader is already partied with joins in standby: the 编制 is the
// leader's to hand out, and the ordinary-party projection assigns the squad when the
// party itself changes.
func TestHandleOnlineCreateRaidLeavesThePartiedMembersInStandby(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: &bufferConn{}, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
storeRuntimePartyState(leader, alignedcmd.PartyState{
PartyID: 1001,
UserID: 1001,
Members: []alignedcmd.PartyMemberState{
{UserID: 1001, UserState: 1, HPPercent: 100, MPPercent: 100},
{UserID: 1002, UserState: 1, HPPercent: 100, MPPercent: 100},
},
})
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketCreateRaid),
buildRaidInfoRequestForBridgeTest(1, []byte("anton"), 0),
)
if err != nil || !handled {
t.Fatalf("create raid handled=%t err=%v", handled, err)
}
state := service.raids[uint32(200)<<16|1001]
if state == nil || len(state.Members) != 2 {
t.Fatalf("runtime raid state = %+v, want the party's two members", state)
}
for _, member := range state.Members {
if member.CharID == 1001 {
if member.GroupIndex != runtimeRaidDefaultGroup {
t.Fatalf("leader landed in 编制 %d, want squad %d", member.GroupIndex, runtimeRaidDefaultGroup)
}
continue
}
if member.GroupIndex != 0 || member.SlotOrder != 0 {
t.Fatalf("invited member 1002 landed in 编制 %d/%d, want standby 0/0", member.GroupIndex, member.SlotOrder)
}
}
}
func TestRuntimeRaidMemberRecordCarriesRepositoryJobAndGrowType(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "1001",
Name: "leader",
Job: "11",
Level: 90,
Stats: map[string]int64{"grow_type": 0x21},
}); err != nil {
t.Fatalf("seed raid character: %v", err)
}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
member := service.newRuntimeRaidMemberLocked(1001, 1, 0)
record := runtimeRaidMemberRecord(runtimeRaidState{LeaderID: 1001}, member)
if record.Field40 != 11 {
t.Fatalf("raid member job field=%d, want 11", record.Field40)
}
if record.Field44 != 0x21 {
t.Fatalf("raid member grow_type=%02x, want 21", record.Field44)
}
}
func TestHandleOnlineRequestRaidInfoConfirmsExistingRuntimeRaid(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
conn := &bufferConn{}
channel := channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023}
session := &gameSession{conn: conn, channel: channel}
service.bindGameSessionCharacter(session, 1)
const raidKey uint32 = 0x00170001
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1,
NameBytes: []byte("anton"),
Members: []runtimeRaidMemberState{
{CharID: 1, Name: "leader", GroupIndex: 1, SlotOrder: 0},
},
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestRaidInfo),
buildRequestRaidInfoBodyForBridgeTest(2, raidKey),
)
if err != nil || !handled {
t.Fatalf("RequestRaidInfo handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("request raid info trailing bytes = %d", len(rest))
}
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestRaidInfo) ||
!bytes.Equal(packet.Body, raid.BuildRaidInfoResponseBody()) {
t.Fatalf("request raid info packet = class %d msg %d body=% X", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
}
func TestHandleOnlineRequestRaidInfoConfirmsUnknownRaidDirectoryRequest(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023}}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRequestRaidInfo),
buildRequestRaidInfoBodyForBridgeTest(1, 0x00170001),
)
if err != nil || !handled {
t.Fatalf("unknown RequestRaidInfo handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("unknown raid request trailing bytes = %d", len(rest))
}
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestRaidInfo) ||
!bytes.Equal(packet.Body, raid.BuildRaidInfoResponseBody()) {
t.Fatalf("unknown raid request packet = class %d msg %d body=% X", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
}
func TestHandleOnlineOtherChannelListSendsNativeEmptyPublicRaidPage(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 221, Type: 23, Name: "anton", Port: 10221},
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRaidOtherChannelList),
[]byte{1},
)
if err != nil || !handled {
t.Fatalf("OtherChannelList handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("other channel list trailing bytes = %d", len(rest))
}
want, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{})
if err != nil {
t.Fatalf("BuildPublicRaidListBody error = %v", err)
}
if packet.Header.Classification != 0 ||
packet.Header.MsgID != raid.RaidPublicListMsgID ||
!bytes.Equal(packet.Body, want) {
t.Fatalf("other channel list packet = class %d msg %d body=% X, want class0 msg %d body=% X",
packet.Header.Classification, packet.Header.MsgID, packet.Body,
raid.RaidPublicListMsgID, want)
}
}
func TestHandleOnlineOtherChannelListFromFiendWarChannelSendsNativeEmptyPublicRaidPage(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 42, Type: 42, Name: "fiendwar", Port: 10042},
}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRaidOtherChannelList),
[]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
)
if err != nil || !handled {
t.Fatalf("FiendWar OtherChannelList handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("FiendWar other channel list trailing bytes = %d", len(rest))
}
want, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{})
if err != nil {
t.Fatalf("BuildPublicRaidListBody error = %v", err)
}
if packet.Header.Classification != 0 ||
packet.Header.MsgID != raid.RaidPublicListMsgID ||
!bytes.Equal(packet.Body, want) {
t.Fatalf("FiendWar other channel list packet = class %d msg %d body=% X, want class0 msg %d body=% X",
packet.Header.Classification, packet.Header.MsgID, packet.Body,
raid.RaidPublicListMsgID, want)
}
}
func TestHandleOnlineOtherChannelListReturnsRecruitingOrdinaryRaidObjects(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
channel: channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023},
}
ordinary := runtimeRaidState{
RaidKey: uint32(23)<<16 | 1001,
LeaderID: 1001,
RouteOrRaidType: 0,
NameBytes: []byte("anton team"),
Members: []runtimeRaidMemberState{{
CharID: 1001,
Name: "Leader",
GroupIndex: 1,
UserState: 1,
}},
}
service.raids[ordinary.RaidKey] = &ordinary
single := cloneRuntimeRaidState(ordinary)
single.RaidKey++
single.TailFlag = 1
service.raids[single.RaidKey] = &single
started := cloneRuntimeRaidState(ordinary)
started.RaidKey += 2
started.Started = true
service.raids[started.RaidKey] = &started
request := []byte{0, 0, 0, 0, 0, 5, 0, 0, 1, 0, 0, 0, 0}
handled, err := service.handleAlignedGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRaidOtherChannelList),
request,
)
if err != nil || !handled {
t.Fatalf("OtherChannelList handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("other channel list trailing bytes = %d", len(rest))
}
want, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{
TotalCount: 1,
Objects: []raid.RaidObjectMode0{runtimeRaidObjectMode0(ordinary)},
})
if err != nil {
t.Fatalf("BuildPublicRaidListBody error = %v", err)
}
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidPublicListMsgID || !bytes.Equal(packet.Body, want) {
t.Fatalf("public raid list packet = class %d msg %d body=% X, want class0 msg %d body=% X",
packet.Header.Classification, packet.Header.MsgID, packet.Body, raid.RaidPublicListMsgID, want)
}
}
func TestOpenPublicRaidDirectoryPushesCreateAndDisbandSnapshotsWithoutAnotherQuery(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
conn := &bufferConn{}
channel := channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023}
session := &gameSession{conn: conn, channel: channel}
service.bindGameSessionCharacter(session, 1001)
directoryRequest := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
if handled, err := service.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRaidOtherChannelList), directoryRequest); err != nil || !handled {
t.Fatalf("initial directory query handled=%t err=%v", handled, err)
}
initial, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 || initial.Header.MsgID != raid.RaidPublicListMsgID {
t.Fatalf("initial directory packet msg=%d trailing=%d", initial.Header.MsgID, len(rest))
}
conn.write.Reset()
if handled, err := service.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketCreateRaid), buildRaidInfoRequestForBridgeTest(0, []byte("anton"), 0)); err != nil || !handled {
t.Fatalf("create raid handled=%t err=%v", handled, err)
}
wantRaidKey := uint32(23)<<16 | 1001
state := service.raids[wantRaidKey]
if state == nil {
t.Fatalf("created raid %08x is missing", wantRaidKey)
}
wantCreated, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{
TotalCount: 1,
Objects: []raid.RaidObjectMode0{runtimeRaidObjectMode0(*state)},
})
if err != nil {
t.Fatalf("build created directory page: %v", err)
}
assertLastPublicRaidDirectoryPacket(t, conn.write.Bytes(), wantCreated)
conn.write.Reset()
// The body carries the sender's own character id. Live traffic confirms the
// field is a character key, not a slot or the raid key: game-000140 (char 1)
// sent 0001 to leave, game-000144 (char 2) sent 0002, and the leader's
// connection sent 0002 to kick member 2. The fixture character is 1001, so the
// leave has to name 1001 -- anything else is what a kick looks like.
leaveBody := make([]byte, 2)
binary.LittleEndian.PutUint16(leaveBody, 1001)
if handled, err := service.handleAlignedGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketLeaveRaid), leaveBody); err != nil || !handled {
t.Fatalf("leave raid handled=%t err=%v", handled, err)
}
wantEmpty, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{})
if err != nil {
t.Fatalf("build empty directory page: %v", err)
}
assertLastPublicRaidDirectoryPacket(t, conn.write.Bytes(), wantEmpty)
}
func assertLastPublicRaidDirectoryPacket(t *testing.T, data []byte, want []byte) {
t.Helper()
var publicBodies [][]byte
for len(data) > 0 {
packet, rest := splitGameServerUpperPacket(t, data)
if packet.Header.Classification == 0 && packet.Header.MsgID == raid.RaidPublicListMsgID {
publicBodies = append(publicBodies, append([]byte(nil), packet.Body...))
}
data = rest
}
if len(publicBodies) == 0 {
t.Fatal("no pushed public raid directory packet")
}
if got := publicBodies[len(publicBodies)-1]; !bytes.Equal(got, want) {
t.Fatalf("last public raid directory body=% X, want=% X", got, want)
}
}
func TestHandleOnlineRaidManagerWorkMovesMemberWithoutAck(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: leaderConn, channel: channel}
member := &gameSession{conn: memberConn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
raidKey := uint32(200)<<16 | 1001
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
NameBytes: []byte("anton"),
Members: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRaidManagerWork),
buildRaidManagerWorkRequestForBridgeTest(0, 1002, 2),
)
if err != nil {
t.Fatalf("handle raid manager work: %v", err)
}
if !handled {
t.Fatalf("RaidManagerWork should be handled by online raid bridge")
}
// The regroup publishes the mode=0 raid object and the mode=3 roster, both on
// 0x024F, separated by the buff registration that mode=0 travels with. Only
// mode=0 carries the attack-party panel the leader's own window is built from,
// and only mode=3 carries the per-row squad index, so both have to go out.
for name, conn := range map[string]*bufferConn{"leader": leaderConn, "member": memberConn} {
packets := splitAllCurrentGameServerUpperPackets(t, conn.write.Bytes())
mode0, mode3, buff := 0, 0, 0
var rosterBody []byte
for _, packet := range packets {
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case raid.RaidMemberRefreshMsgID:
if len(packet.Body) >= 8 && binary.LittleEndian.Uint32(packet.Body[4:8]) == raid.RaidMemberRefreshMode3 {
mode3++
rosterBody = packet.Body
continue
}
mode0++
case raid.RaidBuffMsgID:
buff++
}
}
if mode0 != 1 || mode3 != 1 || buff < 1 {
t.Fatalf("%s regroup packets mode0=%d mode3=%d buff=%d (all=%d)",
name, mode0, mode3, buff, len(packets))
}
assertRaidRefreshMember(t, parseRaidRefreshBodyForTest(t, rosterBody, raidKey), 1002, 2, 0)
}
}
func TestHandleOnlineRaidMemberChangeStateBroadcastsRefreshWithoutAck(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: leaderConn, channel: channel}
member := &gameSession{conn: memberConn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
raidKey := uint32(200)<<16 | 1001
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
NameBytes: []byte("anton"),
Members: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
handled, err := service.handleAlignedGameCommand(
member,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketRaidMemberChangeState),
[]byte{3},
)
if err != nil {
t.Fatalf("handle raid member change state: %v", err)
}
if !handled {
t.Fatalf("RaidMemberChangeState should be handled by online raid bridge")
}
members, rest := splitRaidRefreshPacket(t, memberConn.write.Bytes(), raidKey)
if len(rest) != 0 {
t.Fatalf("member trailing bytes = %d", len(rest))
}
assertRaidRefreshMemberState(t, members, 1002, 1)
members, rest = splitRaidRefreshPacket(t, leaderConn.write.Bytes(), raidKey)
if len(rest) != 0 {
t.Fatalf("leader trailing bytes = %d", len(rest))
}
assertRaidRefreshMemberState(t, members, 1002, 1)
}
func TestHandleOnlineStartRaidCreatesPartiesFromRaidGroups(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002", "1003"} {
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: runtimeRaidEntryItemID, Count: 1},
},
}); err != nil {
t.Fatalf("seed raid ticket for %s: %v", characterID, err)
}
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidAnton: runtimeRaidBridgeTestAntonConfig(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
leaderConn := &bufferConn{}
memberAConn := &bufferConn{}
memberBConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: leaderConn, channel: channel}
memberA := &gameSession{conn: memberAConn, channel: channel}
memberB := &gameSession{conn: memberBConn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(memberA, 1002)
service.bindGameSessionCharacter(memberB, 1003)
raidKey := uint32(200)<<16 | 1001
// The whole roster has to be standing in the raid waiting area before the
// start is accepted (see raid_start_waiting_area.go).
for _, memberSession := range []*gameSession{leader, memberA, memberB} {
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, memberSession, testAntonWaitingAreaSpec(t), raidKey)
}
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
NameBytes: []byte("anton"),
Members: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "memberA", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 90, MPPercent: 80},
{CharID: 1003, Name: "memberB", GroupIndex: 2, SlotOrder: 0, UserState: 1, HPPercent: 70, MPPercent: 60},
},
}
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketStartRaid),
nil,
)
if err != nil {
t.Fatalf("handle start raid: %v", err)
}
if !handled {
t.Fatalf("StartRaid should be handled by online raid bridge")
}
if !service.raids[raidKey].Started {
t.Fatalf("raid was not marked started")
}
ack, _ := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketStartRaid) ||
!bytes.Equal(ack.Body, []byte{1}) {
t.Fatalf("start raid ack = class %d msg %d body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
if got := runtimePartyStateSnapshot(leader); len(got.Members) != 2 || got.Members[0].UserID != 1001 || got.Members[1].UserID != 1002 {
t.Fatalf("leader party state = %+v, want group 1 members 1001/1002", got)
}
if got := runtimePartyStateSnapshot(memberB); len(got.Members) != 1 || got.Members[0].UserID != 1003 {
t.Fatalf("memberB party state = %+v, want solo group 2 member 1003", got)
}
}
func TestHandleOnlineStartRaidRejectsOversizedGroup(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
conn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: conn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
raidKey := uint32(200)<<16 | 1001
state := &runtimeRaidState{RaidKey: raidKey, LeaderID: 1001}
for i := 0; i < runtimeRaidGroupSize+1; i++ {
state.Members = append(state.Members, runtimeRaidMemberState{
CharID: uint16(1001 + i),
GroupIndex: 1,
SlotOrder: byte(i),
UserState: 1,
HPPercent: 100,
MPPercent: 100,
})
}
service.raids[raidKey] = state
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketStartRaid),
nil,
)
if err != nil {
t.Fatalf("handle start raid oversized group: %v", err)
}
if !handled {
t.Fatalf("StartRaid should be handled by online raid bridge")
}
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(rest) != 0 {
t.Fatalf("oversized group trailing bytes = %d", len(rest))
}
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketStartRaid) ||
!bytes.Equal(packet.Body, []byte{0, runtimeRaidFailureCode}) {
t.Fatalf("oversized group response = class %d msg %d body=% X", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
if service.raids[raidKey].Started {
t.Fatalf("oversized group raid was marked started")
}
}
func TestRuntimeRaidSelectorSnapshotPreloadsMapRowsBeforeCreatingView(t *testing.T) {
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain, pvfPath: "test.pvf"}, raids: make(map[uint32]*runtimeRaidState)}
conn := &bufferConn{}
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 23, Type: 23, Name: "anton", Port: 10023}}
service.bindGameSessionCharacter(session, 1001)
state := &runtimeRaidState{
RaidKey: 0x00170001,
LeaderID: 1001,
NameBytes: []byte("anton"),
Started: true,
EntryCostPaid: true,
Members: []runtimeRaidMemberState{{
CharID: 1001, Name: "leader", GroupIndex: 1, UserState: 1, HPPercent: 100, MPPercent: 100,
}},
FrozenMembers: []runtimeRaidMemberState{{
CharID: 1001, Name: "leader", GroupIndex: 1, UserState: 1, HPPercent: 100, MPPercent: 100,
}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseStartedAt: time.Now().UTC().Add(-time.Minute),
PhaseDeadline: time.Now().UTC().Add(time.Hour),
Objectives: map[int]runtimeRaidDungeonObjective{
210: {Phase: runtimeRaidDungeonPhaseOne, MapID: 210, State: raid.AntonDungeonOpen, MaxHP: 1, RemainingHP: 1},
},
},
}
service.raids[state.RaidKey] = state
if err := service.sendRuntimeRaidSelectorSnapshot(session, "test"); err != nil {
t.Fatalf("send selector snapshot: %v", err)
}
wantRefreshBody, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(*state))
if err != nil {
t.Fatal(err)
}
wantIDs := []uint16{
raid.RaidObjectMsgID,
raid.RaidMemberRefreshMsgID,
raid.RaidSymbolMsgID,
raid.RaidDungeonStateMsgID,
raid.RaidTimerMsgID,
raid.RaidRemainTimeMsgID,
}
stream := conn.write.Bytes()
for index, wantID := range wantIDs {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification != 0 || packet.Header.MsgID != wantID {
t.Fatalf("packet[%d] = class %d msg %d, want class0 msg %d", index, packet.Header.Classification, packet.Header.MsgID, wantID)
}
if index == 1 && !bytes.Equal(packet.Body, wantRefreshBody) {
t.Fatalf("selector attack-party refresh body=%x", packet.Body)
}
stream = rest
}
if len(stream) != 0 {
t.Fatalf("selector snapshot replayed an unexpected packet: %x", stream)
}
}
// Standby members belong to no attack group. Folding group 0 into squad 1, which
// this used to do, projected a squad party onto a member the raid roster shows as
// unassigned.
func TestRuntimeRaidPartyGroupsSkipsUnassignedMembers(t *testing.T) {
groups := runtimeRaidPartyGroups(runtimeRaidState{
Members: []runtimeRaidMemberState{
{CharID: 1001, GroupIndex: 0, SlotOrder: 0},
{CharID: 1002, GroupIndex: 2, SlotOrder: 0},
},
})
if len(groups) != 1 || len(groups[0]) != 1 {
t.Fatalf("groups = %+v, want only the assigned member", groups)
}
if groups[0][0].CharID != 1002 || groups[0][0].GroupIndex != 2 {
t.Fatalf("grouped member = %+v, want 1002 in group 2", groups[0][0])
}
// A raid where nobody is assigned projects no parties at all.
if got := runtimeRaidPartyGroups(runtimeRaidState{
Members: []runtimeRaidMemberState{{CharID: 1001, GroupIndex: 0, SlotOrder: 0}},
}); len(got) != 0 {
t.Fatalf("all-standby roster produced groups: %+v", got)
}
}
type raidRefreshMemberForTest struct {
CharID uint16
State byte
GroupIndex byte
SlotOrder byte
}
func buildRaidInfoRequestForBridgeTest(route byte, name []byte, tail byte) []byte {
body := make([]byte, 1+4+len(name)+1)
body[0] = route
binary.LittleEndian.PutUint32(body[1:5], uint32(len(name)))
copy(body[5:], name)
body[len(body)-1] = tail
return body
}
func buildRequestRaidInfoBodyForBridgeTest(mode byte, raidKey uint32) []byte {
body := make([]byte, 5)
body[0] = mode
binary.LittleEndian.PutUint32(body[1:5], raidKey)
return body
}
func buildRaidManagerWorkRequestForBridgeTest(action uint32, member uint32, group uint32) []byte {
body := make([]byte, 12)
binary.LittleEndian.PutUint32(body[0:4], action)
binary.LittleEndian.PutUint32(body[4:8], member)
binary.LittleEndian.PutUint32(body[8:12], group)
return body
}
func splitRaidRefreshPacket(t *testing.T, data []byte, wantRaidKey uint32) ([]raidRefreshMemberForTest, []byte) {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, data)
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidMemberRefreshMsgID {
t.Fatalf("raid refresh packet = class %d msg %d", packet.Header.Classification, packet.Header.MsgID)
}
members := parseRaidRefreshBodyForTest(t, packet.Body, wantRaidKey)
return members, rest
}
func parseRaidRefreshBodyForTest(t *testing.T, body []byte, wantRaidKey uint32) []raidRefreshMemberForTest {
t.Helper()
if len(body) < 9 {
t.Fatalf("raid refresh body too short: %d", len(body))
}
raidKey := binary.LittleEndian.Uint32(body[0:4])
mode := binary.LittleEndian.Uint32(body[4:8])
if raidKey != wantRaidKey || mode != raid.RaidMemberRefreshMode3 {
t.Fatalf("raid refresh header = key 0x%08X mode %d, want key 0x%08X mode %d",
raidKey, mode, wantRaidKey, raid.RaidMemberRefreshMode3)
}
count := int(body[8])
offset := 9
members := make([]raidRefreshMemberForTest, 0, count)
for i := 0; i < count; i++ {
if len(body) < offset+2+1+4 {
t.Fatalf("raid refresh member[%d] truncated before name: offset=%d len=%d", i, offset, len(body))
}
member := raidRefreshMemberForTest{CharID: binary.LittleEndian.Uint16(body[offset : offset+2])}
offset += 2
member.State = body[offset]
offset++
nameLen := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
offset += 4
if len(body) < offset+nameLen+2+2+4+1+1+1+4+2+4 {
t.Fatalf("raid refresh member[%d] truncated after name: offset=%d name_len=%d len=%d", i, offset, nameLen, len(body))
}
offset += nameLen
offset += 2
member.GroupIndex = body[offset]
offset++
member.SlotOrder = body[offset]
offset++
offset += 4 + 1 + 1 + 1 + 4 + 2 + 4
members = append(members, member)
}
if offset != len(body) {
t.Fatalf("raid refresh body trailing bytes = %d", len(body)-offset)
}
return members
}
func assertRaidRefreshMember(t *testing.T, members []raidRefreshMemberForTest, charID uint16, group byte, slot byte) {
t.Helper()
for _, member := range members {
if member.CharID == charID {
if member.GroupIndex != group || member.SlotOrder != slot {
t.Fatalf("member %d = group %d slot %d, want group %d slot %d",
charID, member.GroupIndex, member.SlotOrder, group, slot)
}
return
}
}
t.Fatalf("member %d missing from raid refresh: %+v", charID, members)
}
func assertRaidRefreshMemberState(t *testing.T, members []raidRefreshMemberForTest, charID uint16, state byte) {
t.Helper()
for _, member := range members {
if member.CharID == charID {
if member.State != state {
t.Fatalf("member %d state = %d, want %d", charID, member.State, state)
}
return
}
}
t.Fatalf("member %d missing from raid refresh: %+v", charID, members)
}
func assertRuntimePartySnapshot(t *testing.T, data []byte, wantMembers byte) []byte {
t.Helper()
peerEndpoints, rest := splitGameServerUpperPacket(t, data)
if peerEndpoints.Header.Classification != 0 || peerEndpoints.Header.MsgID != 0x000b {
t.Fatalf("peer endpoint packet = class %d msg %d", peerEndpoints.Header.Classification, peerEndpoints.Header.MsgID)
}
wantPeers := byte(0)
if wantMembers > 0 {
wantPeers = wantMembers - 1
}
if len(peerEndpoints.Body) != 1+int(wantPeers)*22 || peerEndpoints.Body[0] != wantPeers {
t.Fatalf("peer endpoint members = % X, want remote count %d", peerEndpoints.Body, wantPeers)
}
rest = assertCurrentPartyFrameProjection(t, rest, wantMembers, wantMembers > 0)
realtime, rest := splitGameServerUpperPacket(t, rest)
if realtime.Header.Classification != 0 || realtime.Header.MsgID != 0x0099 {
t.Fatalf("realtime packet = class %d msg %d", realtime.Header.Classification, realtime.Header.MsgID)
}
if len(realtime.Body) != 1+int(wantMembers)*5 || realtime.Body[0] != wantMembers {
t.Fatalf("realtime members = % X, want count %d", realtime.Body, wantMembers)
}
for index := 0; index < int(wantMembers); index++ {
offset := 1 + index*5
// +2 is the member's HP percent and +4 its native party slot (see
// party.BuildSingleMemberRealtimeInfo): a multi-member body must carry one
// distinct slot per row.
if realtime.Body[offset+2] != 100 || realtime.Body[offset+3] != 0 || realtime.Body[offset+4] != byte(index) {
t.Fatalf("realtime row %d = % X, want hp=100 helper=0 slot=%d", index, realtime.Body[offset:offset+5], byte(index))
}
}
return rest
}
// assertRuntimePartySceneRefresh validates the deliberately smaller scene
// reconstruction sequence: the complete op9 aggregate precedes op153, while
// op11 is intentionally absent so a town transition does not restart the
// already-established peer relay.
func assertRuntimePartySceneRefresh(t *testing.T, data []byte, wantMembers byte) []byte {
t.Helper()
rest := assertCurrentPartyFrameProjection(t, data, wantMembers, wantMembers > 0)
realtime, rest := splitGameServerUpperPacket(t, rest)
if realtime.Header.Classification != 0 || realtime.Header.MsgID != 0x0099 {
t.Fatalf("scene party realtime packet = class %d msg %d", realtime.Header.Classification, realtime.Header.MsgID)
}
if len(realtime.Body) != 1+int(wantMembers)*5 || realtime.Body[0] != wantMembers {
t.Fatalf("scene party realtime body = % X, want count %d", realtime.Body, wantMembers)
}
return rest
}
// assertRuntimePartyRealtimeRows consumes one bare op153 realtime-rows packet.
//
// It is the closing packet of a member's own party frame: the same-area peer
// projection (a kind-0 op9) is followed by one more op153, because op153 is the only
// carrier of the per-member HP/MP values (party.BuildSingleMemberRealtimeInfo). Without
// it the frame ends on a record whose rows carry no health, and the client's party HUD
// health bars come up empty -- see sendManagedRuntimePartySnapshots.
func assertRuntimePartyRealtimeRows(t *testing.T, data []byte, wantMembers byte) []byte {
t.Helper()
realtime, rest := splitGameServerUpperPacket(t, data)
if realtime.Header.Classification != 0 || realtime.Header.MsgID != 0x0099 {
t.Fatalf("closing realtime packet = class %d msg %d, want the op153 rows",
realtime.Header.Classification, realtime.Header.MsgID)
}
if len(realtime.Body) != 1+int(wantMembers)*5 || realtime.Body[0] != wantMembers {
t.Fatalf("closing realtime members = % X, want count %d", realtime.Body, wantMembers)
}
for index := 0; index < int(wantMembers); index++ {
offset := 1 + index*5
if realtime.Body[offset+2] != 100 || realtime.Body[offset+4] != byte(index) {
t.Fatalf("closing realtime row %d = % X, want hp=100 slot=%d",
index, realtime.Body[offset:offset+5], byte(index))
}
}
return rest
}
func assertRuntimePartyMembershipReduction(t *testing.T, data []byte, wantFrameMembers byte, wantLeaderSlot byte, wantRealtimeMembers byte) []byte {
t.Helper()
frame, data := splitGameServerUpperPacket(t, data)
if frame.Header.Classification != 0 || frame.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
len(frame.Body) < 15 || frame.Body[9] != 2 || frame.Body[10] != wantFrameMembers {
t.Fatalf("party membership reduction = class %d msg %d body=% X, want kind2 count %d",
frame.Header.Classification, frame.Header.MsgID, frame.Body, wantFrameMembers)
}
if got := frame.Body[len(frame.Body)-3]; got != wantLeaderSlot {
t.Fatalf("party membership reduction leader slot=%d want=%d body=% X", got, wantLeaderSlot, frame.Body)
}
realtime, rest := splitGameServerUpperPacket(t, data)
if realtime.Header.Classification != 0 || realtime.Header.MsgID != 0x0099 {
t.Fatalf("party removed realtime packet = class %d msg %d", realtime.Header.Classification, realtime.Header.MsgID)
}
if len(realtime.Body) == 0 || realtime.Body[0] != wantRealtimeMembers {
t.Fatalf("party removed realtime = % X, want count %d", realtime.Body, wantRealtimeMembers)
}
return rest
}
func assertRuntimePartyWalkoutNotice(t *testing.T, data []byte, slot byte, mode byte) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, data)
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestPeer) ||
!bytes.Equal(packet.Body, []byte{slot, mode}) {
t.Fatalf("walkout notice = class %d msg %d body=% X, want class %d msg %d body=% X",
packet.Header.Classification,
packet.Header.MsgID,
packet.Body,
dnfproto.DefaultChannelClassification,
dnfenum.CmdPacketRequestPeer,
[]byte{slot, mode})
}
return rest
}
func assertRuntimePartyChangePosition(t *testing.T, data []byte, fromSlot byte, toSlot byte) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, data)
want := []byte{1, fromSlot, toSlot}
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketChangePartyMemberPosition) ||
!bytes.Equal(packet.Body, want) {
t.Fatalf("change position = class %d msg %d body=% X, want class %d msg %d body=% X",
packet.Header.Classification,
packet.Header.MsgID,
packet.Body,
dnfproto.DefaultChannelClassification,
dnfenum.CmdPacketChangePartyMemberPosition,
want)
}
return rest
}
func assertCurrentPartyFrameProjection(t *testing.T, data []byte, wantMembers byte, wantActive bool) []byte {
t.Helper()
frame, rest := splitGameServerUpperPacket(t, data)
if frame.Header.Classification != 0 || frame.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) {
t.Fatalf("party frame packet = class %d msg %d", frame.Header.Classification, frame.Header.MsgID)
}
if !wantActive {
if wantMembers != 0 {
t.Fatalf("inactive party frame requested with %d members", wantMembers)
}
if len(frame.Body) != 10 ||
binary.LittleEndian.Uint16(frame.Body[0:2]) != 1 ||
binary.LittleEndian.Uint16(frame.Body[2:4]) != currentSceneOp9StableSceneValue ||
frame.Body[9] != currentSceneOp9ActorRemoveKind {
t.Fatalf("inactive party frame is not a kind-3 actor clear: len=%d body=% X", len(frame.Body), frame.Body)
}
return rest
}
if len(frame.Body) < 26 {
t.Fatalf("party frame body too short: len=%d body=% X", len(frame.Body), frame.Body)
}
slotCountOffset := 25
if frame.Body[11] == 0 {
if len(frame.Body) < 16 {
t.Fatalf("party frame custom name header truncated: body=% X", frame.Body)
}
nameLen := int(binary.LittleEndian.Uint32(frame.Body[12:16]))
slotCountOffset = 16 + nameLen + 13
}
if slotCountOffset >= len(frame.Body)-4 {
t.Fatalf("party frame slot offset=%d exceeds body len=%d body=% X", slotCountOffset, len(frame.Body), frame.Body)
}
if frame.Body[slotCountOffset] != wantMembers {
t.Fatalf("party frame slots=%d want=%d body=% X", frame.Body[slotCountOffset], wantMembers, frame.Body)
}
// The byte at len-3 is the authoritative leader slot. Party activity is
// represented by the current slot-table count.
active := frame.Body[slotCountOffset] > 0
if active != wantActive {
t.Fatalf("party frame active=%t want=%t body=% X", active, wantActive, frame.Body)
}
return rest
}
func assertCurrentPartyFrameSelectedSlot(t *testing.T, data []byte, want byte) {
t.Helper()
var frame dnfproto.ChannelPacket
for len(data) > 0 {
packet, rest := splitGameServerUpperPacket(t, data)
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketRecoverStamina) {
frame = packet
break
}
data = rest
}
if frame.Header.Classification != 0 || frame.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) {
t.Fatalf("party frame packet = class %d msg %d", frame.Header.Classification, frame.Header.MsgID)
}
if len(frame.Body) < 4 {
t.Fatalf("party frame body too short for leader slot: len=%d body=% X", len(frame.Body), frame.Body)
}
wantTail := []byte{0, want, 0, 0}
if !bytes.Equal(frame.Body[len(frame.Body)-4:], wantTail) {
t.Fatalf("party frame leader tail=% X want=% X", frame.Body[len(frame.Body)-4:], wantTail)
}
}