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

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

835 lines
33 KiB
Go

package dnfbridge
import (
"bytes"
"encoding/binary"
"reflect"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/modules/dnf/raid"
)
// A joiner lands in standby (group 0) rather than being slotted into the first
// squad with a free seat. The client stores the roster's squad byte verbatim, so
// zero is what makes the member show as unassigned until the leader moves it.
func TestRuntimeRaidRejoinLeavesTheJoinerUnassigned(t *testing.T) {
state := runtimeRaidState{
RaidKey: 0x00c803e9,
LeaderID: 1001,
Members: []runtimeRaidMemberState{
{CharID: 1001, GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, GroupIndex: 1, SlotOrder: 1},
{CharID: 1003, GroupIndex: 1, SlotOrder: 2},
{CharID: 1004, GroupIndex: 1, SlotOrder: 3},
{CharID: 1005, GroupIndex: 2, SlotOrder: 1},
},
}
service := &Service{raids: map[uint32]*runtimeRaidState{state.RaidKey: &state}}
joined, ok := service.rejoinRuntimeRaid(state.RaidKey, 2000)
if !ok {
t.Fatal("rejoin rejected an available raid position")
}
index := runtimeRaidMemberIndex(joined, 2000)
if index < 0 {
t.Fatalf("joined member missing from roster: %+v", joined.Members)
}
if member := joined.Members[index]; member.GroupIndex != 0 || member.SlotOrder != 0 {
t.Fatalf("joined position group=%d slot=%d want group=0 slot=0 (standby)", member.GroupIndex, member.SlotOrder)
}
// Standby must not consume a squad seat: squad 2 still has three free slots
// after the join, so the leader can move someone there.
if count := runtimeRaidGroupCountExcluding(joined, 2, 0); count != 1 {
t.Fatalf("squad 2 occupancy=%d want 1 (only 1005)", count)
}
if !runtimeRaidGroupsEditable(joined) {
t.Fatal("fixture raid is not in an editable state")
}
moved, ok := service.moveRuntimeRaidMember(1001, 2000, 2)
if !ok {
t.Fatal("leader could not move the standby member into squad 2")
}
member := moved.Members[runtimeRaidMemberIndex(moved, 2000)]
if member.GroupIndex != 2 {
t.Fatalf("after the move group=%d want 2", member.GroupIndex)
}
}
func TestRuntimeRaidRejoinRejectsFullAndCrossRaidMembership(t *testing.T) {
t.Run("full", func(t *testing.T) {
const raidKey = uint32(0x00c803e9)
members := make([]runtimeRaidMemberState, 0, raid.MaxAttackPartyMembers)
// Fill the wire-level member cap inside a valid group grid. The grid is
// wider than the cap on purpose (runtimeRaidMaxGroups groups of
// runtimeRaidGroupSize), so the roster is built from the cap, not from
// the grid size.
for len(members) < raid.MaxAttackPartyMembers {
members = append(members, runtimeRaidMemberState{
CharID: uint16(len(members) + 1),
GroupIndex: byte(len(members)/runtimeRaidGroupSize + 1),
SlotOrder: byte(len(members) % runtimeRaidGroupSize),
})
}
state := runtimeRaidState{RaidKey: raidKey, LeaderID: 1, Members: members}
service := &Service{raids: map[uint32]*runtimeRaidState{raidKey: &state}}
if _, ok := service.rejoinRuntimeRaid(raidKey, 3000); ok {
t.Fatal("full 20-member raid accepted another member")
}
if got := len(service.raids[raidKey].Members); got != raid.MaxAttackPartyMembers {
t.Fatalf("full raid roster changed: members=%d", got)
}
})
t.Run("cross_raid", func(t *testing.T) {
const firstKey = uint32(0x00c803e9)
const secondKey = uint32(0x00c803ea)
first := runtimeRaidState{
RaidKey: firstKey,
LeaderID: 1001,
Members: []runtimeRaidMemberState{{CharID: 1001, GroupIndex: 1, SlotOrder: 0}},
}
second := runtimeRaidState{
RaidKey: secondKey,
LeaderID: 2001,
Members: []runtimeRaidMemberState{{CharID: 2001, GroupIndex: 1, SlotOrder: 0}},
}
service := &Service{raids: map[uint32]*runtimeRaidState{firstKey: &first, secondKey: &second}}
if _, ok := service.rejoinRuntimeRaid(secondKey, 1001); ok {
t.Fatal("member already owned by another raid joined a second raid")
}
if runtimeRaidMemberIndex(*service.raids[secondKey], 1001) >= 0 {
t.Fatal("cross-raid rejection still mutated the target roster")
}
})
}
func TestRuntimeRaidRejoinRestoresExactLifecyclePacketSequence(t *testing.T) {
now := time.Date(2026, 8, 6, 4, 0, 0, 0, time.UTC)
rewardBody := []byte{0x72, 0x61, 0x69, 0x64}
tests := []struct {
name string
configure func(*runtimeRaidState)
lifecyclePackets func(runtimeRaidState) []runtimeRaidRejoinExpectedPacket
wantScheduleName string
wantScheduleDelay time.Duration
}{
{
name: "preparing",
configure: func(state *runtimeRaidState) {
state.Lifecycle.Status = runtimeRaidLifecyclePreparing
state.Lifecycle.ReadyDeadline = now.Add(3 * time.Second)
state.Lifecycle.PhaseStartedAt = time.Time{}
},
lifecyclePackets: func(state runtimeRaidState) []runtimeRaidRejoinExpectedPacket {
seconds := uint32(3)
return []runtimeRaidRejoinExpectedPacket{
{msgID: raid.RaidTimerMsgID, body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.ReadyDeadline.Unix()),
Value2: seconds,
})},
{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: seconds})},
}
},
wantScheduleName: "raid-ready:13108201:7",
wantScheduleDelay: 3 * time.Second,
},
{
name: "phase_active",
configure: func(state *runtimeRaidState) {
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.PhaseStartedAt = now.Add(-20 * time.Second)
state.Lifecycle.PhaseDeadline = now.Add(120 * time.Second)
},
lifecyclePackets: func(state runtimeRaidState) []runtimeRaidRejoinExpectedPacket {
symbolBody, dungeonBody, _, err := runtimeRaidPhaseSnapshotBodies(state)
if err != nil {
t.Fatalf("phase snapshot bodies: %v", err)
}
return []runtimeRaidRejoinExpectedPacket{
{msgID: raid.RaidSymbolMsgID, body: symbolBody},
{msgID: raid.RaidDungeonStateMsgID, body: dungeonBody},
{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidPhaseStartState, Argument: 0})},
{msgID: raid.RaidTimerMsgID, body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.PhaseDeadline.Unix()),
Value2: 140,
})},
{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: 120})},
}
},
wantScheduleName: "raid-phase:13108201:1:7",
wantScheduleDelay: 120 * time.Second,
},
{
name: "awaiting_movie",
configure: func(state *runtimeRaidState) {
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.PhaseStartedAt = now.Add(-50 * time.Second)
state.Lifecycle.PhaseDeadline = now.Add(100 * time.Second)
state.Lifecycle.MovieOpenedAt = now.Add(-10 * time.Second)
},
lifecyclePackets: func(runtimeRaidState) []runtimeRaidRejoinExpectedPacket {
resultBody, err := raid.BuildRaidSettlementResultBody(raid.RaidSettlementResult{
ResultType: 0,
PhaseIndex: 0,
ClearSeconds: 40,
})
if err != nil {
t.Fatalf("settlement result body: %v", err)
}
return []runtimeRaidRejoinExpectedPacket{
{msgID: raid.RaidResultMsgID, body: resultBody},
{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidSettlementStateResult, Argument: 0})},
}
},
},
{
name: "awaiting_rewards",
configure: func(state *runtimeRaidState) {
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.RewardDelivery = &runtimeRaidRewardDeliveryCache{
Phase: runtimeRaidDungeonPhaseOne,
Token: 11,
RewardBody: append([]byte(nil), rewardBody...),
}
state.Lifecycle.RewardDeliveryCommitted = true
},
lifecyclePackets: func(runtimeRaidState) []runtimeRaidRejoinExpectedPacket {
return []runtimeRaidRejoinExpectedPacket{
{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidSettlementStateCardsOpened, Argument: 0})},
{msgID: raid.RaidRewardListMsgID, body: rewardBody},
}
},
},
{
name: "standby",
configure: func(state *runtimeRaidState) {
state.Lifecycle.Status = runtimeRaidLifecycleStandby
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.StandbyStartedAt = now.Add(-10 * time.Second)
state.Lifecycle.StandbyDeadline = now.Add(90 * time.Second)
state.Lifecycle.RewardDelivery = &runtimeRaidRewardDeliveryCache{
Phase: runtimeRaidDungeonPhaseOne,
Token: 12,
RewardBody: append([]byte(nil), rewardBody...),
}
state.Lifecycle.RewardDeliveryCommitted = true
},
lifecyclePackets: func(state runtimeRaidState) []runtimeRaidRejoinExpectedPacket {
seconds := uint32(90)
return []runtimeRaidRejoinExpectedPacket{
{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidSettlementStateCardsOpened, Argument: 0})},
{msgID: raid.RaidRewardListMsgID, body: rewardBody},
{msgID: raid.RaidTimerMsgID, body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 1,
Value0: state.RaidKey,
Value1: uint32(state.Lifecycle.StandbyDeadline.Unix()),
Value2: seconds,
})},
{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 1, Seconds: seconds})},
}
},
wantScheduleName: "raid-standby:13108201:7",
wantScheduleDelay: 90 * time.Second,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, session, connection, otherConnection, queue, state := newRuntimeRaidRejoinTestService(t, now)
test.configure(state)
request := make([]byte, 4)
binary.LittleEndian.PutUint32(request, state.RaidKey)
if err := service.handleOnlineRejoinRaid(session, uint16(dnfenum.CmdPacketRejoinRaid), request); err != nil {
t.Fatalf("handle rejoin: %v", err)
}
if otherConnection.write.Len() != 0 {
t.Fatalf("rejoin projected %d bytes to another session", otherConnection.write.Len())
}
want := runtimeRaidRejoinBaseExpectedPackets(t, service, *state)
want = append(want, test.lifecyclePackets(*state)...)
got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes())
assertRuntimeRaidRejoinPackets(t, got, want)
partyState := runtimePartyStateSnapshot(session)
if len(partyState.Members) != 2 ||
partyState.Members[0].UserID != 1001 || partyState.Members[1].UserID != 1002 {
t.Fatalf("rejoin restored wrong group party: %+v", partyState)
}
if test.wantScheduleName == "" {
if queue.scheduleCalls != 0 {
t.Fatalf("unexpected lifecycle schedule: calls=%d name=%q", queue.scheduleCalls, queue.scheduledName)
}
} else if queue.scheduleCalls != 1 || queue.scheduledName != test.wantScheduleName || queue.scheduledDelay != test.wantScheduleDelay {
t.Fatalf("lifecycle schedule calls=%d name=%q delay=%s want name=%q delay=%s",
queue.scheduleCalls, queue.scheduledName, queue.scheduledDelay, test.wantScheduleName, test.wantScheduleDelay)
}
})
}
}
func TestRuntimeRaidRejoinStopsWhenCharacterGenerationChanges(t *testing.T) {
now := time.Date(2026, 8, 6, 5, 0, 0, 0, time.UTC)
service, session, _, otherConnection, queue, state := newRuntimeRaidRejoinTestService(t, now)
connection := &runtimeRaidRejoinGenerationConn{}
session.conn = connection
connection.afterFirstWrite = func() {
service.bindGameSessionCharacter(session, 9000)
}
request := make([]byte, 4)
binary.LittleEndian.PutUint32(request, state.RaidKey)
if err := service.handleOnlineRejoinRaid(session, uint16(dnfenum.CmdPacketRejoinRaid), request); err != nil {
t.Fatalf("generation-changing rejoin: %v", err)
}
got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes())
want := []runtimeRaidRejoinExpectedPacket{{
msgID: uint16(dnfenum.CmdPacketRejoinRaid),
classification: dnfproto.DefaultChannelClassification,
body: raid.BuildRaidCommandSuccessBody(),
}}
assertRuntimeRaidRejoinPackets(t, got, want)
if otherConnection.write.Len() != 0 {
t.Fatalf("generation-changing rejoin wrote %d bytes to another session", otherConnection.write.Len())
}
if queue.scheduleCalls != 0 {
t.Fatalf("stale rejoin scheduled lifecycle callback: calls=%d", queue.scheduleCalls)
}
}
func TestRuntimeRaidSceneRejoinRestoresCreatedRaidAtPostSelectBoundaryOnce(t *testing.T) {
const raidKey = uint32(0x00170001)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "raid-scene-rejoin-created",
channel: channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType},
sceneBootstrapTailSent: true,
}
service.bindGameSessionCharacter(session, 1001)
state := &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
RouteOrRaidType: 0,
NameBytes: []byte("single-anton"),
TailFlag: 1,
SingleRaidType: 0,
Members: []runtimeRaidMemberState{{
CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0,
UserState: 1, HPPercent: 100, MPPercent: 100,
}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecycleCreated,
Revision: 3,
},
}
service.raids[raidKey] = state
staleParty := runtimeRaidGroupPartyState(*state, state.Members)
storeRuntimePartyState(session, staleParty)
if managed, restored := service.bootstrapManagedRuntimePartyForSession(session); !restored || len(managed.Members) != 1 {
t.Fatalf("seed stale single-member party: restored=%t party=%+v", restored, managed)
}
request, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgFollowUpStatus),
nil,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatalf("build Cera request: %v", err)
}
if err := service.handleGameUpper(session, request); err != nil {
t.Fatalf("post-select Cera boundary: %v", err)
}
want := runtimeRaidRejoinBaseExpectedPackets(t, service, *state)[1:]
got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes())
assertRuntimeRaidRejoinPackets(t, got, want)
if !session.raidRejoinProjectionSent ||
session.raidRejoinProjectionRaidKey != raidKey ||
session.raidRejoinProjectionRevision != state.Lifecycle.Revision {
t.Fatalf("scene rejoin cursor not committed: sent=%t key=%#x revision=%d",
session.raidRejoinProjectionSent,
session.raidRejoinProjectionRaidKey,
session.raidRejoinProjectionRevision)
}
if !session.raidDirectoryReadySent {
t.Fatal("raid directory readiness did not follow scene rejoin recovery")
}
partyState := runtimePartyStateSnapshot(session)
if partyState.PartyID > 0 || len(partyState.Members) != 0 {
t.Fatalf("unstarted single raid retained ordinary party projection: %+v", partyState)
}
if managed, found := service.runtimePartyManagerForService().SnapshotByUser(1001, session.characterGeneration); found {
t.Fatalf("unstarted single raid retained central party membership: party=%+v", managed)
}
connection.write.Reset()
if err := service.handleGameUpper(session, request); err != nil {
t.Fatalf("repeated post-select Cera boundary: %v", err)
}
if got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes()); len(got) != 0 {
t.Fatalf("repeated boundary replayed raid packets: %v", runtimeRaidRejoinPacketIDs(got))
}
}
func TestRuntimeRaidSceneRejoinPromotesReconnectSessionBeforeRecovery(t *testing.T) {
const raidKey = uint32(0x00170001)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
onlinePlayers: newOnlinePlayerManager(),
}
resident := &gameSession{
conn: &bufferConn{},
connID: "raid-rejoin-resident",
channel: channelcatalog.Channel{ID: 151, Type: 11},
}
service.bindGameSessionCharacter(resident, 1001)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{CharacterID: 1001, Session: resident})
connection := &bufferConn{}
replacement := &gameSession{
conn: connection,
connID: "raid-rejoin-replacement",
channel: channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType},
channelReconnect: true,
sceneBootstrapTailSent: true,
}
service.bindGameSessionCharacter(replacement, 1001)
if _, current := service.boundGameSessionCharacterSnapshot(replacement); current {
t.Fatal("replacement unexpectedly became canonical before post-select boundary")
}
state := &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
RouteOrRaidType: 0,
NameBytes: []byte("single-anton"),
TailFlag: 1,
SingleRaidType: 0,
Members: []runtimeRaidMemberState{{
CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0,
UserState: 1, HPPercent: 100, MPPercent: 100,
}},
Lifecycle: runtimeRaidLifecycleState{Status: runtimeRaidLifecycleCreated, Revision: 3},
}
service.raids[raidKey] = state
request, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgFollowUpStatus),
nil,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(replacement, request); err != nil {
t.Fatalf("post-select Cera boundary: %v", err)
}
if identity, current := service.boundGameSessionCharacterSnapshot(replacement); !current || identity.character != 1001 {
t.Fatalf("replacement was not promoted before recovery: current=%t identity=%+v", current, identity)
}
if !replacement.raidRejoinProjectionSent {
t.Fatal("promoted replacement did not receive unfinished raid recovery")
}
if got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes()); len(got) == 0 {
t.Fatal("promoted replacement received no raid packets")
}
if resident.conn.(*bufferConn).write.Len() != 0 {
t.Fatal("raid recovery was written to the retired resident socket")
}
}
func TestRuntimeRaidSceneRejoinRestoresActiveProgressAtPostSelectBoundary(t *testing.T) {
now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.UTC)
service, session, connection, otherConnection, queue, state := newRuntimeRaidRejoinTestService(t, now)
session.sceneBootstrapTailSent = true
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.PhaseStartedAt = now.Add(-30 * time.Second)
state.Lifecycle.PhaseDeadline = now.Add(90 * time.Second)
request, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.UpperMsgFollowUpStatus),
nil,
1,
dnfproto.DefaultChannelClassification,
)
if err != nil {
t.Fatalf("build Cera request: %v", err)
}
if err := service.handleGameUpper(session, request); err != nil {
t.Fatalf("active progress post-select boundary: %v", err)
}
symbolBody, dungeonBody, _, err := runtimeRaidPhaseSnapshotBodies(*state)
if err != nil {
t.Fatalf("phase snapshot bodies: %v", err)
}
want := runtimeRaidRejoinBaseExpectedPackets(t, service, *state)[1:]
want = append(want,
runtimeRaidRejoinExpectedPacket{msgID: raid.RaidSymbolMsgID, body: symbolBody},
runtimeRaidRejoinExpectedPacket{msgID: raid.RaidDungeonStateMsgID, body: dungeonBody},
runtimeRaidRejoinExpectedPacket{msgID: raid.RaidStateMsgID, body: raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidPhaseStartState, Argument: 0})},
runtimeRaidRejoinExpectedPacket{msgID: raid.RaidTimerMsgID, body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.PhaseDeadline.Unix()),
Value2: 120,
})},
runtimeRaidRejoinExpectedPacket{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{
TimerType: 0,
Seconds: 90,
})},
)
got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes())
assertRuntimeRaidRejoinPackets(t, got, want)
if otherConnection.write.Len() != 0 {
t.Fatalf("scene rejoin projected %d bytes to another session", otherConnection.write.Len())
}
if managed, found := service.runtimePartyManagerForService().SnapshotByUser(1002, session.characterGeneration); !found || len(managed.Members) != 2 {
t.Fatalf("active scene rejoin did not restore central party membership: found=%t party=%+v", found, managed)
}
if queue.scheduleCalls != 1 || queue.scheduledName != "raid-phase:13108201:1:7" || queue.scheduledDelay != 90*time.Second {
t.Fatalf("restored phase schedule calls=%d name=%q delay=%s", queue.scheduleCalls, queue.scheduledName, queue.scheduledDelay)
}
}
func TestRuntimeRaidSceneRejoinRestoresActiveSingleMapWithoutFakeEntry(t *testing.T) {
now := time.Date(2026, 8, 17, 1, 30, 0, 0, time.UTC)
config := runtimeRaidBridgeTestAntonConfig()
config.SingleMode = &raid.AntonSingleModeConfig{
Name: "single",
TimeLimitSeconds: 2100,
Phase: raid.AntonPhase{Dungeons: []raid.AntonDungeonInfo{
{Index: 5314, InitState: raid.AntonDungeonOpen, HP: 1},
{Index: 5315, InitState: raid.AntonDungeonHide, HP: 1},
}},
}
lifecycle, err := newRuntimeRaidLifecycleStateFromAntonModeAndType(config, now.Add(-time.Minute), true, 0)
if err != nil {
t.Fatalf("create single lifecycle: %v", err)
}
lifecycle.Status = runtimeRaidLifecyclePhaseActive
lifecycle.ReadyDeadline = time.Time{}
lifecycle.PhaseStartedAt = now.Add(-30 * time.Second)
lifecycle.PhaseDeadline = now.Add(90 * time.Second)
member := runtimeRaidMemberState{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100}
state := &runtimeRaidState{
RaidKey: 0x00170001, LeaderID: member.CharID, TailFlag: 1,
Members: []runtimeRaidMemberState{member}, FrozenMembers: []runtimeRaidMemberState{member},
Started: true, EntryCostPaid: true, ChargedMembers: []uint16{member.CharID}, StartAttempt: 1,
AntonConfig: config, Lifecycle: lifecycle,
}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: map[uint32]*runtimeRaidState{state.RaidKey: state},
gameplayTimers: &runtimeRaidPhaseTestQueue{now: now},
}
session := &gameSession{conn: connection, channel: channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType}}
service.bindGameSessionCharacter(session, member.CharID)
identity, current := service.boundGameSessionCharacterSnapshot(session)
if !current {
t.Fatal("single rejoin session is not current")
}
if err := service.sendRuntimeRaidRejoinRecovery(identity, *state, now); err != nil {
t.Fatalf("restore active single raid: %v", err)
}
got := runtimeRaidRejoinRaidPackets(t, connection.write.Bytes())
wantIDs := []uint16{
raid.RaidObjectMsgID, raid.RaidMemberRefreshMsgID, raid.RaidBuffMsgID,
raid.RaidWaitingListMsgID, raid.RaidEntryCostMsgID,
raid.RaidSymbolMsgID, raid.RaidDungeonStateMsgID, raid.RaidStateMsgID, raid.RaidTimerMsgID, raid.RaidRemainTimeMsgID,
}
if ids := runtimeRaidRejoinPacketIDs(got); !reflect.DeepEqual(ids, wantIDs) {
t.Fatalf("active single rejoin packets=%v want=%v", ids, wantIDs)
}
if partyState := runtimePartyStateSnapshot(session); partyState.PartyID > 0 || len(partyState.Members) != 0 {
t.Fatalf("active single rejoin created ordinary party projection: %+v", partyState)
}
if managed, found := service.runtimePartyManagerForService().SnapshotByUser(member.CharID, session.characterGeneration); found {
t.Fatalf("active single rejoin created central ordinary party: %+v", managed)
}
}
func TestRuntimeRaidSceneRejoinSkipsNonRaidChannel(t *testing.T) {
const raidKey = uint32(0x00170001)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: map[uint32]*runtimeRaidState{
raidKey: {
RaidKey: raidKey,
LeaderID: 1001,
Members: []runtimeRaidMemberState{{CharID: 1001, GroupIndex: 1, SlotOrder: 0}},
},
},
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
channel: channelcatalog.Channel{ID: 151, Type: 11},
}
service.bindGameSessionCharacter(session, 1001)
if err := service.sendRuntimeRaidSceneRejoinOnce(session, "ordinary_channel"); err != nil {
t.Fatalf("ordinary-channel scene rejoin: %v", err)
}
if connection.write.Len() != 0 || session.raidRejoinProjectionSent {
t.Fatalf("ordinary channel projected raid state: bytes=%d sent=%t", connection.write.Len(), session.raidRejoinProjectionSent)
}
}
// A raid member who is still unassigned (standby group 0) must survive the scene
// rejoin.
//
// Live incident 2026-09-16 17:36: char 2 re-entered the raid channel after a
// re-login, the server restored their presence, and the session died 10 ms later
// with no other log line in between: sendRuntimeRaidRejoinPartySnapshot answered
// errRuntimeRaidNotMember because the member sat in the standby group, and that
// error travelled up through handleGameUpper into the connection read loop, which
// closes the socket -- the client showed "网络连接中断". An unassigned member is a
// first-class state since joiners land in standby, so it must not be an error.
func TestRuntimeRaidSceneRejoinOfAStandbyMemberKeepsTheSession(t *testing.T) {
now := time.Date(2026, 9, 16, 9, 36, 38, 0, time.UTC)
service, session, connection, _, _, state := newRuntimeRaidRejoinTestService(t, now)
for index := range state.Members {
if state.Members[index].CharID == 1002 {
state.Members[index].GroupIndex = 0
state.Members[index].SlotOrder = 0
}
}
connection.write.Reset()
if err := service.sendRuntimeRaidSceneRejoinOnce(session, "standby_member_rejoin"); err != nil {
t.Fatalf("standby member scene rejoin returned a fatal error: %v", err)
}
if connection.write.Len() == 0 {
t.Fatal("a standby member received no raid rejoin projection at all")
}
if !session.raidRejoinProjectionSent {
t.Fatal("the standby member's raid projection was not recorded")
}
// Nothing reached the party projection: the standby member has no squad, and
// the ordinary town route already published the partyless op9 row.
for _, packet := range runtimeRaidRejoinRaidPackets(t, connection.write.Bytes()) {
if packet.msgID == uint16(dnfenum.CmdPacketRecoverStamina) {
t.Fatalf("standby member received a party projection: %+v", packet)
}
}
}
type runtimeRaidRejoinExpectedPacket struct {
msgID uint16
classification byte
body []byte
}
func newRuntimeRaidRejoinTestService(
t *testing.T,
now time.Time,
) (*Service, *gameSession, *bufferConn, *bufferConn, *runtimeRaidPhaseTestQueue, *runtimeRaidState) {
t.Helper()
const raidKey = uint32(0x00c803e9)
config := runtimeRaidBridgeTestAntonConfig()
lifecycle, err := newRuntimeRaidLifecycleStateFromAnton(config, now)
if err != nil {
t.Fatalf("create lifecycle: %v", err)
}
members := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "rejoin", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1003, Name: "other-group", GroupIndex: 2, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
}
state := &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
NameBytes: []byte("anton"),
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Started: true,
EntryCostPaid: true,
ChargedMembers: []uint16{1001, 1002, 1003},
StartAttempt: 7,
AntonConfig: config,
Lifecycle: lifecycle,
}
queue := &runtimeRaidPhaseTestQueue{now: now}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameplayTimers: queue,
raids: map[uint32]*runtimeRaidState{raidKey: state},
}
connection := &bufferConn{}
otherConnection := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10038}
session := &gameSession{conn: connection, connID: "raid-rejoin-current", channel: channel}
otherSession := &gameSession{conn: otherConnection, connID: "raid-rejoin-other", channel: channel}
service.bindGameSessionCharacter(session, 1002)
service.bindGameSessionCharacter(otherSession, 1001)
return service, session, connection, otherConnection, queue, state
}
func runtimeRaidRejoinBaseExpectedPackets(
t *testing.T,
service *Service,
state runtimeRaidState,
) []runtimeRaidRejoinExpectedPacket {
t.Helper()
objectBody, err := raid.BuildRaidObjectMode0Body(runtimeRaidObjectMode0(state))
if err != nil {
t.Fatalf("build raid object mode0: %v", err)
}
refreshBody, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
if err != nil {
t.Fatalf("build raid member mode3: %v", err)
}
buffBody := mustRuntimeRaidBuffRegistrationSnapshotBody(t, state)
return []runtimeRaidRejoinExpectedPacket{
{
msgID: uint16(dnfenum.CmdPacketRejoinRaid),
classification: dnfproto.DefaultChannelClassification,
body: raid.BuildRaidCommandSuccessBody(),
},
{msgID: raid.RaidObjectMsgID, body: objectBody},
{msgID: raid.RaidMemberRefreshMsgID, body: refreshBody},
{msgID: raid.RaidBuffMsgID, body: buffBody},
{msgID: raid.RaidWaitingListMsgID, body: runtimeRaidWaitingListBody(state)},
{msgID: raid.RaidEntryCostMsgID, body: service.runtimeRaidEntryCostSnapshotBody(state)},
}
}
func runtimeRaidRejoinRaidPackets(t *testing.T, raw []byte) []runtimeRaidRejoinExpectedPacket {
t.Helper()
packets := make([]runtimeRaidRejoinExpectedPacket, 0)
for len(raw) != 0 {
packet, rest := splitGameServerUpperPacket(t, raw)
raw = rest
if !runtimeRaidRejoinIsRaidPacket(packet.Header.MsgID) {
continue
}
packets = append(packets, runtimeRaidRejoinExpectedPacket{
msgID: packet.Header.MsgID,
classification: packet.Header.Classification,
body: append([]byte(nil), packet.Body...),
})
}
return packets
}
func runtimeRaidRejoinIsRaidPacket(msgID uint16) bool {
if msgID == uint16(dnfenum.CmdPacketRejoinRaid) {
return true
}
switch msgID {
case raid.RaidSymbolMsgID,
raid.RaidParticipationMsgID,
raid.RaidDungeonStateMsgID,
raid.RaidDungeonChangeMsgID,
raid.RaidStateMsgID,
raid.RaidMessageMsgID,
raid.RaidObjectMsgID,
raid.RaidWaitingListMsgID,
raid.RaidWaitingChangeMsgID,
raid.RaidTimerMsgID,
raid.RaidRemainTimeMsgID,
raid.RaidEntryCostMsgID,
raid.RaidRewardListMsgID,
raid.RaidResultMsgID,
raid.RaidBuffMsgID,
raid.RaidMonsterStateMsgID:
return true
default:
return false
}
}
func assertRuntimeRaidRejoinPackets(
t *testing.T,
got []runtimeRaidRejoinExpectedPacket,
want []runtimeRaidRejoinExpectedPacket,
) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("raid packet count=%d want=%d\ngot=%v\nwant=%v", len(got), len(want), runtimeRaidRejoinPacketIDs(got), runtimeRaidRejoinPacketIDs(want))
}
for index := range want {
if got[index].msgID != want[index].msgID ||
got[index].classification != want[index].classification ||
!bytes.Equal(got[index].body, want[index].body) {
t.Fatalf("raid packet[%d] class=%d msg=%#04x body=%x want class=%d msg=%#04x body=%x",
index,
got[index].classification,
got[index].msgID,
got[index].body,
want[index].classification,
want[index].msgID,
want[index].body)
}
}
}
// mustRuntimeRaidBuffRegistrationSnapshotBody is the 0x0279 body an untouched raid
// sends. Production builds it from the live activation table, which is
// byte-identical while nothing is running, so the packet assertions that use this
// helper keep pinning the same wire shape.
func mustRuntimeRaidBuffRegistrationSnapshotBody(t *testing.T, state runtimeRaidState) []byte {
t.Helper()
_ = state
body, err := runtimeRaidBuffIdleStatusBody()
if err != nil {
t.Fatalf("build raid Buff registration snapshot: %v", err)
}
return body
}
func assertRuntimeRaidBuffPacket(t *testing.T, raw []byte, state runtimeRaidState) {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, raw)
if len(rest) != 0 || packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidBuffMsgID ||
!bytes.Equal(packet.Body, mustRuntimeRaidBuffRegistrationSnapshotBody(t, state)) {
t.Fatalf("raid Buff packet class=%d msg=%#04x body=%x trailing=%d", packet.Header.Classification, packet.Header.MsgID, packet.Body, len(rest))
}
}
func runtimeRaidRejoinPacketIDs(packets []runtimeRaidRejoinExpectedPacket) []uint16 {
ids := make([]uint16, 0, len(packets))
for _, packet := range packets {
ids = append(ids, packet.msgID)
}
return ids
}
type runtimeRaidRejoinGenerationConn struct {
bufferConn
writes int
afterFirstWrite func()
}
func (connection *runtimeRaidRejoinGenerationConn) Write(data []byte) (int, error) {
written, err := connection.bufferConn.Write(data)
connection.writes++
if connection.writes == 1 && connection.afterFirstWrite != nil {
connection.afterFirstWrite()
}
return written, err
}