Files
90CNServer/server/internal/services/dnfbridge/raid_single_timeout_leave_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

387 lines
17 KiB
Go

package dnfbridge
import (
"bytes"
"encoding/binary"
"errors"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/modules/dnf/raid"
)
func TestActiveSingleRaidLeaveAbandonsRaidAndEndsClientProjection(t *testing.T) {
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
queue := &runtimeRaidPhaseTestQueue{now: now}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
gameplayTimers: queue,
}
connection := &bufferConn{}
session := &gameSession{conn: connection}
service.bindGameSessionCharacter(session, 1001)
storeRuntimeRaidRejoinProjection(session, 4, 9)
service.raids[4] = &runtimeRaidState{
RaidKey: 4,
LeaderID: 1001,
TailFlag: 1,
Started: true,
StartAttempt: 1,
Members: []runtimeRaidMemberState{{CharID: 1001}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseDeadline: now.Add(time.Minute),
},
}
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 4)
if err := service.handleOnlineLeaveRaid(session, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("active single leave: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
!bytes.Equal(packet.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("active leave packet class=%d msg=%d body=% X rest=%d", packet.Header.Classification, packet.Header.MsgID, packet.Body, len(rest))
}
if _, exists := service.raids[4]; exists {
t.Fatal("active single Raid still exists after successful leave")
}
if !runtimeRaidEndedProjectionMatches(session, 4) || runtimeRaidRejoinProjectionMatches(session, 4, 9) {
t.Fatal("single Raid leave did not finalize its session projection")
}
}
func TestCompletedSingleRaidLeaveSucceedsAfterRuntimeFinalization(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidRewardCompleted: make(map[runtimeRaidRewardCompletionKey]runtimeRaidSettlementRewardCompletion),
}
connection := &bufferConn{}
session := &gameSession{conn: connection}
service.bindGameSessionCharacter(session, 1001)
storeRuntimeRaidRejoinProjection(session, 4, 7)
completed := runtimeRaidState{
RaidKey: 4,
LeaderID: 1001,
TailFlag: 1,
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
Lifecycle: runtimeRaidLifecycleState{
Phase: runtimeRaidDungeonPhaseOne,
Status: runtimeRaidLifecycleFinished,
},
}
key := runtimeRaidRewardCompletionKey{RaidKey: 4, Phase: runtimeRaidDungeonPhaseOne, Token: 9}
service.raidRewardCompleted[key] = runtimeRaidSettlementRewardCompletion{Raid: completed}
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 4)
if err := service.handleOnlineLeaveRaid(session, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("completed single Raid leave: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
!bytes.Equal(packet.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("completed leave packet class=%d msg=%d body=% X rest=%d", packet.Header.Classification, packet.Header.MsgID, packet.Body, len(rest))
}
if !runtimeRaidEndedProjectionMatches(session, 4) || runtimeRaidRejoinProjectionMatches(session, 4, 7) {
t.Fatal("completed single Raid leave did not finalize its session projection")
}
}
// TestCompletedTeamRaidLeaveSucceedsAfterRuntimeFinalization is the operator's
// 2026-09-20 report "二阶段通关 攻坚流程结束后 不能退出攻坚队 点击退出没反应": the
// leave request arrived at a raid the server had already released, matched no branch
// and was answered with the silent failure body {0,3} -- eleven times in the live log
// (11:30:55 … 11:47:06, e.g. SEND … msg_id=665 hex …150000000003).
func TestCompletedTeamRaidLeaveSucceedsAfterRuntimeFinalization(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidRewardCompleted: make(map[runtimeRaidRewardCompletionKey]runtimeRaidSettlementRewardCompletion),
}
connection := &bufferConn{}
leaver := &gameSession{conn: connection}
service.bindGameSessionCharacter(leaver, 13)
// Raid 1507329 is the live raid of the report; its key's low u16 is the
// CREATOR's character id (1), not the leaver's (13).
teamRaidKey := uint32(1507329)
storeRuntimeRaidRejoinProjection(leaver, teamRaidKey, 7)
// The leaver still holds the ordinary party the raid projected for his squad.
storeRuntimePartyState(leaver, alignedcmd.PartyState{
PartyID: 999, UserID: 13, IsLeader: true, MaxMembers: 4,
Members: []alignedcmd.PartyMemberState{{UserID: 13, UserState: 1, HPPercent: 100, MPPercent: 100}},
})
// The team raid was released by finalizeRuntimeRaid, so only the committed
// completion record still knows the roster.
completed := runtimeRaidState{
RaidKey: 1507329,
LeaderID: 1,
TailFlag: 0,
FrozenMembers: []runtimeRaidMemberState{
{CharID: 1, GroupIndex: 1, SlotOrder: 0, UserState: 1},
{CharID: 13, GroupIndex: 1, SlotOrder: 1, UserState: 1},
},
Lifecycle: runtimeRaidLifecycleState{
Phase: runtimeRaidDungeonPhaseTwo,
Status: runtimeRaidLifecycleFinished,
},
}
key := runtimeRaidRewardCompletionKey{RaidKey: 1507329, Phase: runtimeRaidDungeonPhaseTwo, Token: 11}
service.raidRewardCompleted[key] = runtimeRaidSettlementRewardCompletion{Raid: completed}
// The live client sends its OWN character id for a self-leave (measured:
// "0d 00" for char 13, "0a 00" for char 10), not the team raid key.
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 13)
if err := service.handleOnlineLeaveRaid(leaver, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("completed team Raid leave: %v", err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
!bytes.Equal(ack.Body, raid.BuildLeaveRaidResultBody(false)) {
t.Fatalf("completed team leave ack class=%d msg=%d body=% X, want the success body % X",
ack.Header.Classification, ack.Header.MsgID, ack.Body, raid.BuildLeaveRaidResultBody(false))
}
// The leaver's client drops the object it materialized, exactly like an ordinary
// leave: the raid window must not stay up on a raid the player has left.
removal, rest := splitGameServerUpperPacket(t, rest)
if removal.Header.MsgID != raid.RaidObjectMsgID ||
!bytes.Equal(removal.Body, raid.BuildRaidObjectRemoveBody(1507329)) {
t.Fatalf("completed team leave sent msg=%d body=% X, want the raid object removal % X",
removal.Header.MsgID, removal.Body, raid.BuildRaidObjectRemoveBody(1507329))
}
if len(rest) == 0 {
t.Fatal("a completed team leave did not clear the party the raid projected")
}
if remaining := runtimePartyStateSnapshot(leaver); remaining.PartyID != 0 {
t.Fatalf("the leaver still projects party %d after leaving the finished raid", remaining.PartyID)
}
if !runtimeRaidEndedProjectionMatches(leaver, uint16(teamRaidKey)) ||
runtimeRaidRejoinProjectionMatches(leaver, teamRaidKey, 7) {
t.Fatal("completed team Raid leave did not finalize its session projection")
}
// A character in no finished team raid still gets the failure body: the new
// branch must not become a blanket success.
stranger := &gameSession{conn: &bufferConn{}}
service.bindGameSessionCharacter(stranger, 4242)
if err := service.handleOnlineLeaveRaid(stranger, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("stranger leave: %v", err)
}
failure, _ := splitGameServerUpperPacket(t, stranger.conn.(*bufferConn).write.Bytes())
if bytes.Equal(failure.Body, raid.BuildLeaveRaidResultBody(false)) {
t.Fatalf("a character outside every finished raid was acknowledged as a leaver: body=% X", failure.Body)
}
}
func TestActiveRaidLeaveKeepsTeamRaidAndMismatchedSingleKeyRejected(t *testing.T) {
tests := []struct {
name string
tailFlag byte
requestKey uint16
}{
{name: "team Raid", tailFlag: 0, requestKey: 4},
{name: "single mismatched key", tailFlag: 1, requestKey: 5},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
connection := &bufferConn{}
session := &gameSession{conn: connection}
service.bindGameSessionCharacter(session, 1001)
service.raids[4] = &runtimeRaidState{
RaidKey: 4,
LeaderID: 1001,
TailFlag: test.tailFlag,
Started: true,
Members: []runtimeRaidMemberState{{CharID: 1001}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
Lifecycle: runtimeRaidLifecycleState{Status: runtimeRaidLifecyclePhaseActive, Phase: 1},
}
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, test.requestKey)
if err := service.handleOnlineLeaveRaid(session, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("rejected active leave: %v", err)
}
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
bytes.Equal(packet.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("rejected leave packet msg=%d body=% X rest=%d", packet.Header.MsgID, packet.Body, len(rest))
}
if _, exists := service.raids[4]; !exists {
t.Fatal("rejected active leave removed Raid")
}
})
}
}
func TestSingleRaidPhaseExpiryPublishesEndedAndAcceptsLeaveRetry(t *testing.T) {
now := time.Date(2026, 8, 18, 21, 0, 0, 0, time.UTC)
deadline := now.Add(time.Minute)
queue := &runtimeRaidPhaseTestQueue{now: now}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
gameplayTimers: queue,
}
connection := &bufferConn{}
session := &gameSession{conn: connection}
service.bindGameSessionCharacter(session, 1001)
storeRuntimeRaidRejoinProjection(session, 4, 11)
state := runtimeRaidState{
RaidKey: 4,
LeaderID: 1001,
TailFlag: 1,
Started: true,
StartAttempt: 3,
Members: []runtimeRaidMemberState{{CharID: 1001}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseStartedAt: now,
PhaseDeadline: deadline,
},
}
service.raids[4] = &state
if err := service.scheduleRuntimeRaidPhaseExpiry(state); err != nil {
t.Fatalf("schedule expiry: %v", err)
}
queue.callback(deadline)
// An expired attack window is not a teardown. 86JP fails the phase first
// (RunAttackTimeoutAsync -> TryFailPhase) and only hands the terminal object to
// a ten-second remover afterwards, so the first packet is the closed gate table
// and the raid must still be readable while the result window is open.
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.MsgID != raid.RaidDungeonStateMsgID || len(rest) == 0 {
t.Fatalf("expiry packet msg=%d body=% X rest=%d, want the failure presentation",
packet.Header.MsgID, packet.Body, len(rest))
}
if failed := service.raids[4]; failed == nil || failed.Lifecycle.Status != runtimeRaidLifecycleFailed {
t.Fatalf("expired single Raid = %+v, want a failed raid kept for its result window", failed)
}
if runtimeRaidEndedProjectionMatches(session, 4) || !runtimeRaidRejoinProjectionMatches(session, 4, 11) {
t.Fatal("the failure presentation released the raid before its result window")
}
// The expiry callback armed the delayed removal, so the queue now holds that
// callback instead of a second expiry: firing it again is what releases the
// failed raid and tells the member it is over.
connection.write.Reset()
queue.callback(deadline)
release, releaseRest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(releaseRest) != 0 || release.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
!bytes.Equal(release.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("release packet msg=%d body=% X rest=%d", release.Header.MsgID, release.Body, len(releaseRest))
}
if _, exists := service.raids[4]; exists {
t.Fatal("failed single Raid outlived its result window")
}
if !runtimeRaidEndedProjectionMatches(session, 4) || runtimeRaidRejoinProjectionMatches(session, 4, 11) {
t.Fatal("release did not finalize its session projection")
}
connection.write.Reset()
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 4)
if err := service.handleOnlineLeaveRaid(session, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("leave retry after expiry: %v", err)
}
retry, retryRest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(retryRest) != 0 || !bytes.Equal(retry.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("expiry leave retry body=% X rest=%d", retry.Body, len(retryRest))
}
}
func TestSingleRaidPhaseExpiryWriteFailureKeepsLeaveRetryProof(t *testing.T) {
now := time.Date(2026, 8, 18, 22, 0, 0, 0, time.UTC)
deadline := now.Add(time.Minute)
queue := &runtimeRaidPhaseTestQueue{now: now}
connection := &runtimeRaidPhaseTestConn{
remainingFailures: 1,
writeErr: errors.New("timeout notification write failed"),
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
gameplayTimers: queue,
}
session := &gameSession{conn: connection}
service.bindGameSessionCharacter(session, 1001)
storeRuntimeRaidRejoinProjection(session, 4, 13)
state := runtimeRaidState{
RaidKey: 4,
LeaderID: 1001,
TailFlag: 1,
Started: true,
StartAttempt: 5,
Members: []runtimeRaidMemberState{{CharID: 1001}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1001}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseStartedAt: now,
PhaseDeadline: deadline,
},
}
service.raids[4] = &state
if err := service.scheduleRuntimeRaidPhaseExpiry(state); err != nil {
t.Fatalf("schedule expiry: %v", err)
}
queue.callback(deadline)
// The failure presentation could not be written, but the expiry itself is
// already committed and the delayed removal was armed before the write, so the
// socket error can neither revive the phase nor strand the raid in the table.
if connection.write.Len() != 0 {
t.Fatalf("failed failure presentation wrote %d bytes", connection.write.Len())
}
if failed := service.raids[4]; failed == nil || failed.Lifecycle.Status != runtimeRaidLifecycleFailed {
t.Fatalf("expired single Raid = %+v, want a failed raid still awaiting its removal", failed)
}
// Even with the presentation lost, the removal still carries the retry proof:
// the release stores the ended projection, clears the rejoin projection and
// answers op665 with the success body on the now-healthy socket.
queue.callback(deadline.Add(runtimeRaidFailedResultWindow))
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveRaid) ||
!bytes.Equal(packet.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("release packet msg=%d body=% X rest=%d", packet.Header.MsgID, packet.Body, len(rest))
}
if _, exists := service.raids[4]; exists {
t.Fatal("expired single Raid survived its result window")
}
if !runtimeRaidEndedProjectionMatches(session, 4) || runtimeRaidRejoinProjectionMatches(session, 4, 13) {
t.Fatal("release discarded the retry proof")
}
connection.write.Reset()
body := make([]byte, 2)
binary.LittleEndian.PutUint16(body, 4)
if err := service.handleOnlineLeaveRaid(session, uint16(dnfenum.CmdPacketLeaveRaid), body); err != nil {
t.Fatalf("leave retry after write failure: %v", err)
}
retry, retryRest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(retryRest) != 0 || !bytes.Equal(retry.Body, raid.BuildLeaveRaidResultBody(true)) {
t.Fatalf("write-failure retry body=% X rest=%d", retry.Body, len(retryRest))
}
if runtimeRaidRejoinProjectionMatches(session, 4, 13) {
t.Fatal("successful retry retained the stale rejoin projection")
}
}