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

805 lines
34 KiB
Go

package dnfbridge
import (
"bytes"
"encoding/binary"
"errors"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/modules/dnf/raid"
)
func TestRuntimeRaidPhaseSettlementPublishesResultBeforeState3(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
now := time.Date(2026, 8, 6, 4, 0, 0, 0, time.UTC)
result := raid.RaidSettlementResult{
ResultType: 2,
PhaseIndex: 0,
ClearSeconds: 321,
// DeathCount is owned by the phase ledger, not by the caller: publishing
// overwrites it with the accepted-op40 count for this raid and phase
// (raid_phase_death_count.go). This fixture recorded no deaths, so the
// body must carry 0 whatever the caller passes.
DeathCount: 4,
Score: 5,
Flags: 6,
}
if err := service.publishRuntimeRaidPhaseSettlement(raidKey, runtimeRaidDungeonPhaseOne, result, now); err != nil {
t.Fatalf("publish phase settlement: %v", err)
}
if got := service.runtimeRaidPhaseDeathCount(raidKey, runtimeRaidDungeonPhaseOne); got != 0 {
t.Fatalf("fixture unexpectedly recorded %d deaths", got)
}
result.DeathCount = 0
wantResult, err := raid.BuildRaidSettlementResultBody(result)
if err != nil {
t.Fatalf("build expected result: %v", err)
}
for _, test := range []struct {
name string
session *gameSession
}{
{name: "leader", session: leader},
{name: "member", session: member},
} {
t.Run(test.name, func(t *testing.T) {
conn := test.session.conn.(*bufferConn)
resultPacket, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if resultPacket.Header.Classification != 0 || resultPacket.Header.MsgID != raid.RaidResultMsgID || !bytes.Equal(resultPacket.Body, wantResult) {
t.Fatalf("result packet class=%d msg=%#04x body=% X", resultPacket.Header.Classification, resultPacket.Header.MsgID, resultPacket.Body)
}
statePacket, rest := splitGameServerUpperPacket(t, rest)
if statePacket.Header.Classification != 0 || statePacket.Header.MsgID != raid.RaidStateMsgID || !bytes.Equal(statePacket.Body, raid.BuildRaidStateBody(raid.RaidState{State: 3})) {
t.Fatalf("state packet class=%d msg=%#04x body=% X", statePacket.Header.Classification, statePacket.Header.MsgID, statePacket.Body)
}
if len(rest) != 0 {
t.Fatalf("trailing bytes=%d", len(rest))
}
})
}
state := service.raids[raidKey]
if state == nil || state.Lifecycle.Status != runtimeRaidLifecycleAwaitingMovie || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
t.Fatalf("lifecycle after settlement=%+v", state)
}
}
func TestRuntimeRaidPhaseSettlementRejectsWirePhaseMismatchBeforeMutation(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
err := service.publishRuntimeRaidPhaseSettlement(
raidKey,
runtimeRaidDungeonPhaseOne,
raid.RaidSettlementResult{PhaseIndex: 1},
time.Now().UTC(),
)
if !errors.Is(err, errRuntimeRaidSettlementPhaseResult) {
t.Fatalf("phase mismatch error=%v", err)
}
if leader.conn.(*bufferConn).write.Len() != 0 || member.conn.(*bufferConn).write.Len() != 0 {
t.Fatalf("phase mismatch wrote packets")
}
if got := service.raids[raidKey].Lifecycle.Status; got != runtimeRaidLifecyclePhaseActive {
t.Fatalf("phase mismatch mutated lifecycle status=%d", got)
}
}
func TestRuntimeRaidPhaseSettlementRejectsInvalidResultBeforeMutation(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
err := service.publishRuntimeRaidPhaseSettlement(
raidKey,
runtimeRaidDungeonPhaseOne,
raid.RaidSettlementResult{ResultType: 4, PhaseIndex: 0},
time.Now().UTC(),
)
if err == nil {
t.Fatal("invalid settlement result was accepted")
}
if leader.conn.(*bufferConn).write.Len() != 0 || member.conn.(*bufferConn).write.Len() != 0 {
t.Fatal("invalid settlement result wrote packets")
}
if got := service.raids[raidKey].Lifecycle.Status; got != runtimeRaidLifecyclePhaseActive {
t.Fatalf("invalid settlement result mutated lifecycle status=%d", got)
}
}
func TestRuntimeRaidStage0AckPrecedesMovieNotification(t *testing.T) {
service, leader, _, raidKey := newRuntimeRaidResultFlowBridgeService(t)
if err := service.publishRuntimeRaidPhaseSettlement(
raidKey,
runtimeRaidDungeonPhaseOne,
raid.RaidSettlementResult{PhaseIndex: 0},
time.Now().UTC(),
); err != nil {
t.Fatalf("open movie: %v", err)
}
conn := leader.conn.(*bufferConn)
conn.write.Reset()
handled, err := service.handleAlignedGameCommand(
leader,
byte(1),
raid.RaidResultStageRequestMsgID,
[]byte{0},
)
if err != nil || !handled {
t.Fatalf("stage0 handled=%t err=%v", handled, err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != raid.RaidResultStageRequestMsgID || len(ack.Body) != 0 {
t.Fatalf("stage0 ACK class=%d msg=%#04x body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
movie, rest := splitGameServerUpperPacket(t, rest)
if movie.Header.Classification != 0 || movie.Header.MsgID != raid.RaidMovieMsgID || len(movie.Body) != 0 {
t.Fatalf("movie class=%d msg=%#04x body=% X", movie.Header.Classification, movie.Header.MsgID, movie.Body)
}
if len(rest) != 0 {
t.Fatalf("stage0 trailing bytes=%d", len(rest))
}
}
func TestRuntimeRaidMovieCompletionPublishesState4CountdownThenState5(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
now := time.Date(2026, 8, 18, 3, 0, 0, 0, time.UTC)
queue := &runtimeRaidPhaseTestQueue{now: now}
service.gameplayTimers = queue
service.raids[raidKey].Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
service.raids[raidKey].Lifecycle.RewardOpenedAt = now
state := cloneRuntimeRaidState(*service.raids[raidKey])
if err := service.broadcastRuntimeRaidMovieComplete(state); err != nil {
t.Fatalf("broadcast movie complete: %v", err)
}
for _, session := range []*gameSession{leader, member} {
conn := session.conn.(*bufferConn)
assertRuntimeRaidMovieCountdownPackets(t, conn.write.Bytes())
}
if queue.scheduledDelay != runtimeRaidRewardOpenCountdown || queue.callback == nil {
t.Fatalf("cards-open schedule delay=%s callback_nil=%t", queue.scheduledDelay, queue.callback == nil)
}
leaderBefore := leader.conn.(*bufferConn).write.Len()
memberBefore := member.conn.(*bufferConn).write.Len()
queue.callback(now.Add(runtimeRaidRewardOpenCountdown))
assertRuntimeRaidCardsOpenedPacket(t, leader.conn.(*bufferConn).write.Bytes()[leaderBefore:])
assertRuntimeRaidCardsOpenedPacket(t, member.conn.(*bufferConn).write.Bytes()[memberBefore:])
// Opening the cards is also the moment the window's one automatic settlement
// is armed: a member that never picks must not hold the whole squad.
if got := queue.scheduledName; got != "raid-reward-auto-flip:13108201:1:1" {
t.Fatalf("auto-flip schedule name=%q", got)
}
if queue.scheduledDelay != runtimeRaidRewardAutoFlipDelay || queue.callback == nil {
t.Fatalf("auto-flip schedule delay=%s callback_nil=%t", queue.scheduledDelay, queue.callback == nil)
}
}
func TestRuntimeRaidStage1AckPrecedesRosterState4Countdown(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
state := cloneRuntimeRaidState(*service.raids[raidKey])
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
service.raids[raidKey].Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
owner := runtimeRaidSettlementMovieOwnerFunc(func(characterID uint16, phase byte, now time.Time) (runtimeRaidState, bool, error) {
if characterID != 1001 || phase != runtimeRaidDungeonPhaseOne || now.IsZero() {
t.Fatalf("movie owner character=%d phase=%d now=%v", characterID, phase, now)
}
return state, true, nil
})
if err := service.handleRuntimeRaidResultStageWithOwner(leader, []byte{1}, owner); err != nil {
t.Fatalf("stage1: %v", err)
}
leaderConn := leader.conn.(*bufferConn)
ack, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != raid.RaidResultStageRequestMsgID || len(ack.Body) != 0 {
t.Fatalf("stage1 ACK class=%d msg=%#04x body=% X", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
assertRuntimeRaidMovieCountdownPackets(t, rest)
assertRuntimeRaidMovieCountdownPackets(t, member.conn.(*bufferConn).write.Bytes())
}
func TestRuntimeRaidStage1ProductionOwnerWaitsForFrozenRosterOnSamePhase(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
service.raids[raidKey].Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
if err := service.handleRuntimeRaidResultStage(leader, []byte{1}); err != nil {
t.Fatalf("leader stage1: %v", err)
}
leaderAck, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if leaderAck.Header.MsgID != raid.RaidResultStageRequestMsgID || len(leaderAck.Body) != 0 || len(rest) != 0 {
t.Fatalf("leader stage1 ACK msg=%#04x body=% X trailing=%d", leaderAck.Header.MsgID, leaderAck.Body, len(rest))
}
if member.conn.(*bufferConn).write.Len() != 0 {
t.Fatalf("first stage1 confirmation broadcast before frozen roster completed")
}
if got := service.raids[raidKey].Lifecycle.Status; got != runtimeRaidLifecycleAwaitingMovie {
t.Fatalf("first confirmation status=%d want awaiting movie", got)
}
leader.conn.(*bufferConn).write.Reset()
if err := service.handleRuntimeRaidResultStage(member, []byte{1}); err != nil {
t.Fatalf("member stage1: %v", err)
}
memberAck, rest := splitGameServerUpperPacket(t, member.conn.(*bufferConn).write.Bytes())
if memberAck.Header.MsgID != raid.RaidResultStageRequestMsgID || len(memberAck.Body) != 0 {
t.Fatalf("member stage1 ACK msg=%#04x body=% X", memberAck.Header.MsgID, memberAck.Body)
}
assertRuntimeRaidMovieCountdownPackets(t, rest)
assertRuntimeRaidMovieCountdownPackets(t, leader.conn.(*bufferConn).write.Bytes())
state := service.raids[raidKey]
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
t.Fatalf("movie roster completion status=%d phase=%d", state.Lifecycle.Status, state.Lifecycle.Phase)
}
}
func TestRuntimeRaidStage1AckWriteFailureRemainsRetryableAfterRosterConfirmation(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
service.raids[raidKey].Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
if err := service.handleRuntimeRaidResultStage(leader, []byte{1}); err != nil {
t.Fatalf("leader stage1: %v", err)
}
leader.conn.(*bufferConn).write.Reset()
wantWriteErr := errors.New("stage1 ACK write failed")
failing := &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}
member.conn = failing
if err := service.handleRuntimeRaidResultStage(member, []byte{1}); !errors.Is(err, wantWriteErr) {
t.Fatalf("last member stage1 write error=%v want=%v", err, wantWriteErr)
}
if failing.write.Len() != 0 {
t.Fatalf("failed stage1 ACK partially wrote=%d bytes", failing.write.Len())
}
stateAfterFailure := service.raids[raidKey]
if stateAfterFailure.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards ||
stateAfterFailure.Lifecycle.Phase != runtimeRaidDungeonPhaseOne ||
!stateAfterFailure.Lifecycle.MovieConfirmed[1001] ||
!stateAfterFailure.Lifecycle.MovieConfirmed[1002] {
t.Fatalf("state after failed ACK=%+v", stateAfterFailure.Lifecycle)
}
revisionAfterFailure := stateAfterFailure.Lifecycle.Revision
retryConn := &bufferConn{}
member.conn = retryConn
if err := service.handleRuntimeRaidResultStage(member, []byte{1}); err != nil {
t.Fatalf("last member stage1 retry: %v", err)
}
if got := service.raids[raidKey].Lifecycle.Revision; got != revisionAfterFailure {
t.Fatalf("stage1 retry changed lifecycle revision=%d want=%d", got, revisionAfterFailure)
}
retryAck, rest := splitGameServerUpperPacket(t, retryConn.write.Bytes())
if retryAck.Header.Classification != dnfproto.DefaultChannelClassification ||
retryAck.Header.MsgID != raid.RaidResultStageRequestMsgID || len(retryAck.Body) != 0 {
t.Fatalf("retry ACK class=%d msg=%#04x body=% X", retryAck.Header.Classification, retryAck.Header.MsgID, retryAck.Body)
}
assertRuntimeRaidMovieCountdownPackets(t, rest)
assertRuntimeRaidMovieCountdownPackets(t, leader.conn.(*bufferConn).write.Bytes())
}
func TestRuntimeRaidRewardCardPublishesAfterDurableCommitThenStandbyTimer(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
state := cloneRuntimeRaidState(*service.raids[raidKey])
cardOwner := &runtimeRaidSettlementCardOwnerStub{state: state}
endUnix := uint32(0x11223344)
timer := raid.RaidTimer{TimerType: 1, Value0: raidKey, Value1: endUnix, Value2: 600}
rewards := raid.RaidSettlementRewards{
ResultType: 1,
Members: []raid.RaidSettlementMemberRewards{
{
MemberKey: 1001,
CardIndex: 7,
Rewards: []raid.RaidSettlementReward{
{Kind: 0, ItemIDOrValue: 10093974, CountOrAux: 5},
},
},
{MemberKey: 1002, CardIndex: 8},
},
}
rewardOwner := &runtimeRaidSettlementRewardOwnerStub{
state: state,
token: 0x55667788,
rewards: rewards,
timer: &timer,
}
if err := service.handleRuntimeRaidRewardCardWithOwners(
leader,
[]byte{1, 7},
cardOwner,
rewardOwner,
); err != nil {
t.Fatalf("mode1: %v", err)
}
mode1Ack, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if mode1Ack.Header.MsgID != raid.RaidRewardCardRequestMsgID || !bytes.Equal(mode1Ack.Body, []byte{1}) || len(rest) != 0 {
t.Fatalf("mode1 ACK msg=%#04x body=% X trailing=%d", mode1Ack.Header.MsgID, mode1Ack.Body, len(rest))
}
if rewardOwner.commits != 0 || rewardOwner.completions != 0 {
t.Fatalf("mode1 invoked reward delivery commit=%d complete=%d", rewardOwner.commits, rewardOwner.completions)
}
leader.conn.(*bufferConn).write.Reset()
if err := service.handleRuntimeRaidRewardCardWithOwners(
leader,
[]byte{0, 7},
cardOwner,
rewardOwner,
); err != nil {
t.Fatalf("mode0: %v", err)
}
if rewardOwner.commits != 1 || rewardOwner.completions != 1 || rewardOwner.completedToken != rewardOwner.token {
t.Fatalf("reward owner commit=%d complete=%d token=%#x", rewardOwner.commits, rewardOwner.completions, rewardOwner.completedToken)
}
// The reveal board is scoped to the recipient's own squad: 1001 is squad 1 and
// 1002 is squad 2 in this fixture, so each receives a body holding only its own
// row of the same settlement and never the other squad's card.
wantLeaderRewards, err := raid.BuildRaidSettlementRewardsBody(raid.RaidSettlementRewards{
ResultType: rewards.ResultType,
Members: []raid.RaidSettlementMemberRewards{rewards.Members[0]},
})
if err != nil {
t.Fatalf("build expected leader rewards: %v", err)
}
wantMemberRewards, err := raid.BuildRaidSettlementRewardsBody(raid.RaidSettlementRewards{
ResultType: rewards.ResultType,
Members: []raid.RaidSettlementMemberRewards{rewards.Members[1]},
})
if err != nil {
t.Fatalf("build expected member rewards: %v", err)
}
if bytes.Equal(wantLeaderRewards, wantMemberRewards) {
t.Fatal("the fixture's two squads produced one shared reward body")
}
// The whole-raid team row follows both reveals unchanged: one resultType-3 row
// per settled member (a member with no resolved row contributes none), sent to
// every member. Both squads must receive the identical body, which is what makes
// the attack-party board complete again (see runtimeRaidTeamSquadRewardBody).
teamRows := make([]raid.RaidSettlementMemberRewards, 0, len(rewards.Members))
for _, row := range rewards.Members {
if len(row.Rewards) == 0 {
continue
}
teamRows = append(teamRows, raid.RaidSettlementMemberRewards{
MemberKey: row.MemberKey,
CardIndex: 2,
Rewards: []raid.RaidSettlementReward{row.Rewards[len(row.Rewards)-1]},
})
}
wantTeamRewards, err := raid.BuildRaidSettlementRewardsBody(raid.RaidSettlementRewards{
ResultType: runtimeRaidTeamRewardResultType,
Members: teamRows,
})
if err != nil {
t.Fatalf("build expected team rewards: %v", err)
}
wantTimer := raid.BuildRaidTimerBody(timer)
mode0Ack, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if mode0Ack.Header.Classification != dnfproto.DefaultChannelClassification || mode0Ack.Header.MsgID != raid.RaidRewardCardRequestMsgID || !bytes.Equal(mode0Ack.Body, []byte{1}) {
t.Fatalf("mode0 ACK class=%d msg=%#04x body=% X", mode0Ack.Header.Classification, mode0Ack.Header.MsgID, mode0Ack.Body)
}
assertRuntimeRaidRewardAndTimerPackets(t, rest, wantLeaderRewards, wantTeamRewards, wantTimer)
assertRuntimeRaidRewardAndTimerPackets(t, member.conn.(*bufferConn).write.Bytes(), wantMemberRewards, wantTeamRewards, wantTimer)
}
// TestRuntimeRaidTeamRewardBoardIsRaisedForEverySquadShape pins the whole-raid
// board to the reference's condition, which is none: 86JP broadcasts the
// resultType-3 attack-party row to every member of the eligible roster whatever
// the party layout is (RaidHandler.Rewards.cs:602-622). Live 2026-09-17T12:46Z,
// the phase-two settlement of raid 1507329 skipped it because its two members had
// merged onto the default squad, and the 团队展示 stage of that flip had no board
// behind it.
func TestRuntimeRaidTeamRewardBoardIsRaisedForEverySquadShape(t *testing.T) {
delivery := func(groups ...byte) runtimeRaidSettlementRewardDelivery {
state := runtimeRaidState{RaidKey: 0x00c803e9}
rewards := raid.RaidSettlementRewards{ResultType: 1}
for index, group := range groups {
characterID := uint16(1001 + index)
state.FrozenMembers = append(state.FrozenMembers, runtimeRaidMemberState{
CharID: characterID,
GroupIndex: group,
})
rewards.Members = append(rewards.Members, raid.RaidSettlementMemberRewards{
MemberKey: characterID,
CardIndex: byte(index),
Rewards: []raid.RaidSettlementReward{
{Kind: 0, ItemIDOrValue: 10093974, CountOrAux: 5},
{Kind: 0, ItemIDOrValue: 3330, CountOrAux: 150},
},
})
}
return runtimeRaidSettlementRewardDelivery{Raid: state, Token: 1, Rewards: rewards}
}
shapes := []struct {
name string
groups []byte
}{
{name: "two squads", groups: []byte{1, 2}},
{name: "one squad", groups: []byte{1, 1}},
{name: "both unassigned", groups: []byte{0, 0}},
{name: "unassigned beside one", groups: []byte{0, 1}},
}
for _, shape := range shapes {
body, ok, err := runtimeRaidTeamSquadRewardBody(delivery(shape.groups...))
if err != nil {
t.Fatalf("%s: %v", shape.name, err)
}
if !ok {
t.Fatalf("%s: the attack-party board was skipped", shape.name)
}
if len(body) < 2 || body[0] != runtimeRaidTeamRewardResultType || int(body[1]) != len(shape.groups) {
t.Fatalf("%s: body=% X", shape.name, body)
}
}
if _, ok, err := runtimeRaidTeamSquadRewardBody(runtimeRaidSettlementRewardDelivery{}); err != nil || ok {
t.Fatalf("empty delivery ok=%v err=%v", ok, err)
}
}
func TestRuntimeRaidRewardPublicationStopsWhenRecipientCharacterGenerationChanges(t *testing.T) {
fixture := newRuntimeRaidRewardTestFixture(t, true)
delivery, err := fixture.service.commitRuntimeRaidSettlementRewards(
fixture.raidKey,
runtimeRaidDungeonPhaseOne,
fixture.now,
)
if err != nil {
t.Fatalf("commit rewards: %v", err)
}
if len(delivery.InventoryRefreshes) == 0 {
t.Fatal("committed delivery has no inventory refreshes")
}
connection := &runtimeRaidResultGenerationConn{}
fixture.leader.conn = connection
connection.afterFirstWrite = func() {
fixture.service.bindGameSessionCharacter(fixture.leader, 1999)
}
publication := &runtimeRaidRewardPublication{
RewardListMembers: make(map[uint16]bool),
StandbyTimerMembers: make(map[uint16]bool),
}
if err := fixture.service.publishRuntimeRaidRewardListLocked(
delivery,
delivery.RewardBody,
publication,
); err != nil {
t.Fatalf("publish rewards across generation change: %v", err)
}
if publication.RewardListMembers[1001] {
t.Fatal("stale leader generation was marked as published")
}
if !publication.RewardListMembers[1002] {
t.Fatal("unchanged member generation was not published")
}
first, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if first.Header.MsgID == raid.RaidRewardListMsgID || len(trailing) != 0 {
t.Fatalf("stale generation received reward list: first=%#04x trailing=%x", first.Header.MsgID, trailing)
}
replacement := &gameSession{conn: &bufferConn{}, channel: fixture.leader.channel}
fixture.service.bindGameSessionCharacter(replacement, 1001)
if err := fixture.service.publishRuntimeRaidRewardListLocked(
delivery,
delivery.RewardBody,
publication,
); err != nil {
t.Fatalf("retry reward publication on replacement session: %v", err)
}
if !publication.RewardListMembers[1001] {
t.Fatal("replacement session reward publication was not recorded")
}
raw := replacement.conn.(*bufferConn).write.Bytes()
packetIDs := make([]uint16, 0, 2)
for len(raw) != 0 {
var packet dnfproto.ChannelPacket
packet, raw = splitGameServerUpperPacket(t, raw)
packetIDs = append(packetIDs, packet.Header.MsgID)
}
if len(packetIDs) < 2 || packetIDs[len(packetIDs)-1] != raid.RaidRewardListMsgID {
t.Fatalf("replacement session packets=%v, want inventory refresh then reward list", packetIDs)
}
}
func TestRuntimeRaidRewardCardCommitFailureReturnsFailureAndRemainsRetryable(t *testing.T) {
service, leader, member, raidKey := newRuntimeRaidResultFlowBridgeService(t)
state := cloneRuntimeRaidState(*service.raids[raidKey])
cardOwner := &runtimeRaidSettlementCardOwnerStub{state: state}
wantCommitErr := errors.New("reward commit failed")
rewardOwner := &runtimeRaidSettlementRewardOwnerStub{
state: state,
token: 0x10203040,
commitErr: wantCommitErr,
}
if err := service.handleRuntimeRaidRewardCardWithOwners(leader, []byte{1, 7}, cardOwner, rewardOwner); err != nil {
t.Fatalf("mode1: %v", err)
}
leader.conn.(*bufferConn).write.Reset()
if err := service.handleRuntimeRaidRewardCardWithOwners(leader, []byte{0, 7}, cardOwner, rewardOwner); err != nil {
t.Fatalf("failed mode0 commit ACK: %v", err)
}
failure, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if failure.Header.MsgID != raid.RaidRewardCardRequestMsgID ||
!bytes.Equal(failure.Body, []byte{0, runtimeRaidFailureCode}) || len(rest) != 0 {
t.Fatalf("commit failure packet msg=%#04x body=% X trailing=%d", failure.Header.MsgID, failure.Body, len(rest))
}
if rewardOwner.commits != 1 || rewardOwner.completions != 0 || member.conn.(*bufferConn).write.Len() != 0 {
t.Fatalf("failed commit calls=%d/%d member_bytes=%d", rewardOwner.commits, rewardOwner.completions, member.conn.(*bufferConn).write.Len())
}
rewardOwner.commitErr = nil
leader.conn.(*bufferConn).write.Reset()
if err := service.handleRuntimeRaidRewardCardWithOwners(leader, []byte{0, 7}, cardOwner, rewardOwner); err != nil {
t.Fatalf("mode0 retry: %v", err)
}
success, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if success.Header.MsgID != raid.RaidRewardCardRequestMsgID || !bytes.Equal(success.Body, []byte{1}) {
t.Fatalf("retry ACK msg=%#04x body=% X", success.Header.MsgID, success.Body)
}
rewardList, rest := splitGameServerUpperPacket(t, rest)
if rewardList.Header.MsgID != raid.RaidRewardListMsgID || len(rest) != 0 {
t.Fatalf("retry reward msg=%#04x trailing=%d", rewardList.Header.MsgID, len(rest))
}
if rewardOwner.commits != 2 || rewardOwner.completions != 1 {
t.Fatalf("retry commit calls=%d/%d", rewardOwner.commits, rewardOwner.completions)
}
}
func TestRuntimeRaidBehaviorAndMessageAreParsedWithoutAck(t *testing.T) {
service, leader, _, _ := newRuntimeRaidResultFlowBridgeService(t)
conn := leader.conn.(*bufferConn)
// 661 is a behavior report: parsed and answered with nothing, as before.
handled, err := service.handleAlignedGameCommand(
leader,
byte(1),
raid.RaidDoBehaviorRequestMsgID,
[]byte{1, 0, 0, 0, 2, 0},
)
if err != nil || !handled {
t.Fatalf("opcode=%d handled=%t err=%v", raid.RaidDoBehaviorRequestMsgID, handled, err)
}
if conn.write.Len() != 0 {
t.Fatalf("661 wrote %d bytes despite its native no-ACK handler", conn.write.Len())
}
conn.write.Reset()
// 663 is the raid message request. It used to be dropped. The banner turns out
// to be a chat MODE rather than a dedicated opcode (86: IsRaidMessageMode(52|53)
// with mode 53 gated on the raid leader), and this client answered the labelled
// sweep with 56.
handled, err = service.handleAlignedGameCommand(
leader,
byte(1),
raid.RaidMessageRequestMsgID,
[]byte{3, 2, 0, 0, 0, 'o', 'k', 4},
)
if err != nil || !handled {
t.Fatalf("opcode=%d handled=%t err=%v", raid.RaidMessageRequestMsgID, handled, err)
}
if conn.write.Len() == 0 {
t.Fatal("663 wrote nothing; the leader's announcement did not run")
}
first, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
if first.Header.MsgID != currentTownChatNotificationMsgID {
t.Fatalf("663 answer msg=%#04x want %#04x", first.Header.MsgID, currentTownChatNotificationMsgID)
}
want := buildCurrentTownChatNotification(currentRaidAnnouncementMode, leader.selectedCharacterID, []byte("ok"))
if !bytes.Equal(first.Body, want) {
t.Fatalf("663 answer body=% X want % X", first.Body, want)
}
}
func TestRuntimeRaidRewardCardInvalidBodyUsesNativeFailureShape(t *testing.T) {
service, leader, _, _ := newRuntimeRaidResultFlowBridgeService(t)
handled, err := service.handleAlignedGameCommand(
leader,
byte(1),
raid.RaidRewardCardRequestMsgID,
[]byte{3, 0},
)
if err != nil || !handled {
t.Fatalf("invalid card handled=%t err=%v", handled, err)
}
packet, rest := splitGameServerUpperPacket(t, leader.conn.(*bufferConn).write.Bytes())
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
packet.Header.MsgID != raid.RaidRewardCardRequestMsgID ||
!bytes.Equal(packet.Body, []byte{0, runtimeRaidFailureCode}) {
t.Fatalf("failure packet class=%d msg=%#04x body=% X", packet.Header.Classification, packet.Header.MsgID, packet.Body)
}
if len(rest) != 0 {
t.Fatalf("invalid card trailing bytes=%d", len(rest))
}
}
func newRuntimeRaidResultFlowBridgeService(t *testing.T) (*Service, *gameSession, *gameSession, uint32) {
t.Helper()
const raidKey = uint32(0x00c803e9)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10038}
leader := &gameSession{conn: &bufferConn{}, channel: channel}
member := &gameSession{conn: &bufferConn{}, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
members := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 2, SlotOrder: 0},
}
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Started: true,
EntryCostPaid: true,
ChargedMembers: []uint16{1001, 1002},
StartAttempt: 1,
Lifecycle: newRuntimeRaidLifecycleState(time.Date(2026, 8, 6, 3, 0, 0, 0, time.UTC)),
}
// The settlement pays the members the phase registered a clear for; the fixture is
// the state a reached settlement implies.
seedRuntimeRaidPhaseClearsForTest(&service.raids[raidKey].Lifecycle, 1001, 1002)
return service, leader, member, raidKey
}
type runtimeRaidSettlementMovieOwnerFunc func(uint16, byte, time.Time) (runtimeRaidState, bool, error)
type runtimeRaidResultGenerationConn struct {
bufferConn
writes int
afterFirstWrite func()
}
func (connection *runtimeRaidResultGenerationConn) 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
}
func (owner runtimeRaidSettlementMovieOwnerFunc) confirmRuntimeRaidSettlementMovie(
characterID uint16,
phase byte,
now time.Time,
) (runtimeRaidState, bool, error) {
return owner(characterID, phase, now)
}
type runtimeRaidSettlementCardOwnerStub struct {
state runtimeRaidState
previewIndex byte
previewed bool
}
func (owner *runtimeRaidSettlementCardOwnerStub) selectRuntimeRaidSettlementCard(
_ uint16,
_ byte,
request raid.RaidRewardCardRequest,
_ time.Time,
) (runtimeRaidState, bool, error) {
switch request.Mode {
case 1:
owner.previewIndex = request.CardIndex
owner.previewed = true
return owner.state, false, nil
case 0:
if !owner.previewed || owner.previewIndex != request.CardIndex {
return runtimeRaidState{}, false, errors.New("mode0 did not match mode1")
}
return owner.state, true, nil
default:
return runtimeRaidState{}, false, errors.New("unexpected mode")
}
}
type runtimeRaidSettlementRewardOwnerStub struct {
state runtimeRaidState
token uint64
rewards raid.RaidSettlementRewards
timer *raid.RaidTimer
commitErr error
commits int
completions int
completedToken uint64
}
func (owner *runtimeRaidSettlementRewardOwnerStub) commitRuntimeRaidSettlementRewards(
_ uint32,
_ byte,
_ time.Time,
) (runtimeRaidSettlementRewardDelivery, error) {
owner.commits++
if owner.commitErr != nil {
return runtimeRaidSettlementRewardDelivery{}, owner.commitErr
}
return runtimeRaidSettlementRewardDelivery{
Raid: owner.state,
Token: owner.token,
Rewards: owner.rewards,
}, nil
}
func (owner *runtimeRaidSettlementRewardOwnerStub) completeRuntimeRaidSettlementRewards(
_ uint32,
_ byte,
token uint64,
_ time.Time,
) (runtimeRaidSettlementRewardCompletion, error) {
owner.completions++
owner.completedToken = token
return runtimeRaidSettlementRewardCompletion{Raid: owner.state, StandbyTimer: owner.timer}, nil
}
func assertRuntimeRaidMovieCountdownPackets(t *testing.T, raw []byte) {
t.Helper()
state4, rest := splitGameServerUpperPacket(t, raw)
timer, rest := splitGameServerUpperPacket(t, rest)
remain, rest := splitGameServerUpperPacket(t, rest)
if state4.Header.Classification != 0 || state4.Header.MsgID != raid.RaidStateMsgID || !bytes.Equal(state4.Body, raid.BuildRaidStateBody(raid.RaidState{State: 4})) {
t.Fatalf("state4 class=%d msg=%#04x body=% X", state4.Header.Classification, state4.Header.MsgID, state4.Body)
}
if timer.Header.Classification != 0 || timer.Header.MsgID != raid.RaidTimerMsgID || len(timer.Body) != 13 || timer.Body[0] != 0 || !bytes.Equal(timer.Body[9:], []byte{3, 0, 0, 0}) {
t.Fatalf("countdown timer class=%d msg=%#04x body=% X", timer.Header.Classification, timer.Header.MsgID, timer.Body)
}
if remain.Header.Classification != 0 || remain.Header.MsgID != raid.RaidRemainTimeMsgID || !bytes.Equal(remain.Body, raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: 3})) {
t.Fatalf("countdown remain class=%d msg=%#04x body=% X", remain.Header.Classification, remain.Header.MsgID, remain.Body)
}
if len(rest) != 0 {
t.Fatalf("movie countdown trailing bytes=%d", len(rest))
}
}
func assertRuntimeRaidCardsOpenedPacket(t *testing.T, raw []byte) {
t.Helper()
state5, rest := splitGameServerUpperPacket(t, raw)
if state5.Header.Classification != 0 || state5.Header.MsgID != raid.RaidStateMsgID || !bytes.Equal(state5.Body, raid.BuildRaidStateBody(raid.RaidState{State: 5})) {
t.Fatalf("state5 class=%d msg=%#04x body=% X", state5.Header.Classification, state5.Header.MsgID, state5.Body)
}
if len(rest) != 0 {
t.Fatalf("cards-open trailing bytes=%d", len(rest))
}
}
// assertRuntimeRaidRewardAndTimerPackets checks the presentation a member
// receives after a committed settlement: its own squad's reveal rows, the
// whole-raid team row when the settlement has more than one squad (wantTeam
// non-nil), and the standby timer with its remain-time twin.
func assertRuntimeRaidRewardAndTimerPackets(t *testing.T, raw []byte, wantRewards []byte, wantTeam []byte, wantTimer []byte) {
t.Helper()
rewardPacket, rest := splitGameServerUpperPacket(t, raw)
if rewardPacket.Header.Classification != 0 || rewardPacket.Header.MsgID != raid.RaidRewardListMsgID || !bytes.Equal(rewardPacket.Body, wantRewards) {
t.Fatalf("reward packet class=%d msg=%#04x body=% X", rewardPacket.Header.Classification, rewardPacket.Header.MsgID, rewardPacket.Body)
}
if wantTeam != nil {
teamPacket, teamRest := splitGameServerUpperPacket(t, rest)
if teamPacket.Header.Classification != 0 || teamPacket.Header.MsgID != raid.RaidRewardListMsgID || !bytes.Equal(teamPacket.Body, wantTeam) {
t.Fatalf("team reward packet class=%d msg=%#04x body=% X want=% X", teamPacket.Header.Classification, teamPacket.Header.MsgID, teamPacket.Body, wantTeam)
}
rest = teamRest
}
timerPacket, rest := splitGameServerUpperPacket(t, rest)
if timerPacket.Header.Classification != 0 || timerPacket.Header.MsgID != raid.RaidTimerMsgID || !bytes.Equal(timerPacket.Body, wantTimer) {
t.Fatalf("timer packet class=%d msg=%#04x body=% X", timerPacket.Header.Classification, timerPacket.Header.MsgID, timerPacket.Body)
}
// The break countdown is read from 0x0255 timerType 1 while the raid object
// state is the phase-settlement value 5, so every 0x0254 standby timer is
// followed by its remain-time twin carrying whole seconds bounded by the break
// duration the timer itself announces (0 when the fixture's clock is already
// past the deadline, which the client draws as expired).
remainPacket, rest := splitGameServerUpperPacket(t, rest)
duration := binary.LittleEndian.Uint32(wantTimer[9:13])
remaining := binary.LittleEndian.Uint32(remainPacket.Body[1:5])
if remainPacket.Header.Classification != 0 || remainPacket.Header.MsgID != raid.RaidRemainTimeMsgID ||
len(remainPacket.Body) != 5 || remainPacket.Body[0] != 1 || remaining > duration {
t.Fatalf("standby remain packet class=%d msg=%#04x body=% X duration=%d",
remainPacket.Header.Classification, remainPacket.Header.MsgID, remainPacket.Body, duration)
}
if len(rest) != 0 {
t.Fatalf("reward/timer trailing bytes=%d", len(rest))
}
}