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