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