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

760 lines
37 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"sync"
"sync/atomic"
"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"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRuntimeRaidStartFreezesOnlineRosterAndOrdersAckBeforeCostSync(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002"} {
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: runtimeRaidEntryItemID, Count: 2},
},
}); err != nil {
t.Fatalf("seed ticket for %s: %v", characterID, err)
}
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidAnton: runtimeRaidBridgeTestAntonConfig(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "raid", Port: 10038}
leader := &gameSession{conn: leaderConn, channel: channel}
member := &gameSession{conn: memberConn, channel: channel}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
registerPartyPeerEndpointForTest(leader, 3, 5063)
registerPartyPeerEndpointForTest(member, 3, 60359)
raidKey := uint32(200)<<16 | 1001
// Both members stand in the raid waiting area: the start command refuses a
// roster that is not all there (see raid_start_waiting_area.go).
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, leader, testAntonWaitingAreaSpec(t), raidKey)
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, member, testAntonWaitingAreaSpec(t), raidKey)
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
NameBytes: []byte("anton"),
Members: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0, UserState: 1, HPPercent: 100, MPPercent: 100},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1, UserState: 1, HPPercent: 100, MPPercent: 100},
},
}
handled, err := service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketStartRaid),
nil,
)
if err != nil || !handled {
t.Fatalf("start handled=%t err=%v", handled, err)
}
state := service.raids[raidKey]
if state == nil || !state.Started || !state.EntryCostPaid || state.StartPending {
t.Fatalf("started state=%+v", state)
}
if got := runtimeRaidMemberIDs(runtimeRaidState{Members: state.FrozenMembers}); !sameRuntimeRaidMembers(state.FrozenMembers, []uint16{1001, 1002}) || len(got) != 2 {
t.Fatalf("frozen members=%v", got)
}
if state.Lifecycle.Status != runtimeRaidLifecyclePreparing || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
t.Fatalf("lifecycle status=%d phase=%d", state.Lifecycle.Status, state.Lifecycle.Phase)
}
ack, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketStartRaid) ||
!bytes.Equal(ack.Body, raid.BuildRaidCommandSuccessBody()) {
t.Fatalf("first leader packet class=%d msg=%d body=% X, want start ACK", ack.Header.Classification, ack.Header.MsgID, ack.Body)
}
costSync, rest := splitGameServerUpperPacket(t, rest)
if costSync.Header.Classification != 0 || costSync.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("second leader packet class=%d msg=%d, want op14 cost sync", costSync.Header.Classification, costSync.Header.MsgID)
}
_, rest = splitRaidRefreshPacket(t, rest, raidKey)
rest = assertRuntimeRaidBuffRegistrationPrefix(t, rest, *state)
rest = assertRuntimePartySnapshot(t, rest, 2)
// A roster that is really standing in the waiting area receives one extra op9
// aggregate before the ready timer: the town co-presence frame for the mate
// that shares the area. It is not part of the start handshake and both the
// pre-gate and post-gate server send it identically (A/B on 2026-09-20, same
// fixture with presence rows); it appears here only because this fixture now
// models a roster that can actually start on the live server.
rest = assertCurrentPartyFrameProjection(t, rest, 2, true)
// That peer projection is a kind-0 op9, so the publish closes the frame with the
// op153 rows again: op153 is the only carrier of the per-member HP/MP values, and
// a frame left on the op9 shows empty health bars and blocks gate entry.
rest = assertRuntimePartyRealtimeRows(t, rest, 2)
rest = assertRuntimeRaidReadyTimer(t, rest, raidKey)
// Past the pinned handshake the start appends the entry-counter refresh: the
// charge that just happened is what 帐号日入场数 / 帐号周入场场数 / 角色周入场次数
// render, and the client never queries those numbers, so they have to be
// re-pushed here or every member keeps reading the pre-charge values.
rest = assertRuntimeRaidEntryCounterRefresh(t, rest)
if len(rest) != 0 {
t.Fatalf("leader trailing bytes=%d", len(rest))
}
memberCostSync, memberRest := splitGameServerUpperPacket(t, memberConn.write.Bytes())
if memberCostSync.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("first member packet msg=%d, want op14", memberCostSync.Header.MsgID)
}
_, memberRest = splitRaidRefreshPacket(t, memberRest, raidKey)
memberRest = assertRuntimeRaidBuffRegistrationPrefix(t, memberRest, *state)
memberRest = assertRuntimePartySnapshot(t, memberRest, 2)
memberRest = assertCurrentPartyFrameProjection(t, memberRest, 2, true)
memberRest = assertRuntimePartyRealtimeRows(t, memberRest, 2)
memberRest = assertRuntimeRaidReadyTimer(t, memberRest, raidKey)
// Every frozen member is charged, so every member gets the refresh -- not just
// the one who issued the start command.
memberRest = assertRuntimeRaidEntryCounterRefresh(t, memberRest)
if len(memberRest) != 0 {
t.Fatalf("member trailing bytes=%d", len(memberRest))
}
if len(state.PendingCostSync) != 0 {
t.Fatalf("pending cost sync=%d after all online writes", len(state.PendingCostSync))
}
if _, ok := service.modifyRuntimeRaidInfo(1001, raid.InfoRequest{RouteOrRaidType: 2}); ok {
t.Fatalf("frozen raid accepted metadata modification")
}
// A frozen roster still refuses metadata and regrouping, but leaving is not a
// roster mutation the freeze can hold: the 86JP reference removes a leaver
// without consulting the raid state at all (RaidManager.LeaveLocked), and
// refusing it left the player stuck inside a raid they could not leave.
if _, removed, _ := service.removeRuntimeRaidMember(1002); !removed {
t.Fatalf("frozen raid refused leave")
}
if _, ok := service.moveRuntimeRaidMember(1001, 1002, 2); ok {
t.Fatalf("frozen raid accepted regroup")
}
if _, _, ok := service.setRuntimeRaidWaiting(1002, 1); ok {
t.Fatalf("frozen raid accepted waiting mutation")
}
leaderConn.write.Reset()
memberConn.write.Reset()
handled, err = service.handleAlignedGameCommand(
leader,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketStartRaid),
nil,
)
if err != nil || !handled {
t.Fatalf("idempotent start handled=%t err=%v", handled, err)
}
replayACK, replayRest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
if replayACK.Header.MsgID != uint16(dnfenum.CmdPacketStartRaid) || !bytes.Equal(replayACK.Body, []byte{1}) {
t.Fatalf("replay first packet msg=%d body=% X", replayACK.Header.MsgID, replayACK.Body)
}
next, _ := splitGameServerUpperPacket(t, replayRest)
if next.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("idempotent start replayed a completed cost sync")
}
for _, characterID := range []string{"1001", "1002"} {
inventory, found, loadErr := repositories.Inventory.Load(context.Background(), characterID)
if loadErr != nil || !found || inventory.Slots["0:0"].Count != 1 {
t.Fatalf("inventory %s found=%t err=%v record=%+v", characterID, found, loadErr, inventory)
}
}
}
func TestRuntimeRaidStartRejectsOfflineRosterBeforeDurableDebit(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002"} {
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: runtimeRaidEntryItemID, Count: 1},
},
}); err != nil {
t.Fatalf("seed ticket: %v", err)
}
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
raidAnton: runtimeRaidBridgeTestAntonConfig(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
conn := &bufferConn{}
leader := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 200, Type: 23}}
service.bindGameSessionCharacter(leader, 1001)
raidKey := uint32(200)<<16 | 1001
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
Members: []runtimeRaidMemberState{
{CharID: 1001, GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, GroupIndex: 1, SlotOrder: 1},
},
}
handled, err := service.handleAlignedGameCommand(leader, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketStartRaid), nil)
if err != nil || !handled {
t.Fatalf("offline start handled=%t err=%v", handled, err)
}
state := service.raids[raidKey]
if state.Started || state.StartPending || state.EntryCostPaid || len(state.FrozenMembers) != 0 {
t.Fatalf("offline start state=%+v", state)
}
for _, characterID := range []string{"1001", "1002"} {
inventory, found, loadErr := repositories.Inventory.Load(context.Background(), characterID)
if loadErr != nil || !found || inventory.Slots["0:0"].Count != 1 {
t.Fatalf("offline validation debited %s: found=%t err=%v record=%+v", characterID, found, loadErr, inventory)
}
}
}
func TestRuntimeRaidDungeonLifecycleUsesPhaseAndTokenWithoutAutomaticAdvance(t *testing.T) {
start := time.Date(2026, 8, 6, 1, 0, 0, 0, time.UTC)
service, raidKey := newRuntimeRaidLifecycleService(start)
ordinary, err := service.beginRuntimeRaidDungeon(1001, 5000, 10)
if err != nil || ordinary.RaidKey != 0 {
t.Fatalf("ordinary binding=%+v err=%v", ordinary, err)
}
binding, err := service.beginRuntimeRaidDungeon(1001, 210, 11)
if err != nil {
t.Fatalf("begin phase-one map: %v", err)
}
if binding.RaidKey != raidKey || binding.Phase != runtimeRaidDungeonPhaseOne || binding.MapID != 210 || binding.LifecycleToken != 11 {
t.Fatalf("binding=%+v", binding)
}
if _, err := service.beginRuntimeRaidDungeon(1001, 211, 12); !errors.Is(err, errRuntimeRaidGroupOccupied) {
t.Fatalf("same group second map error=%v", err)
}
selected := runtimeRaidDungeonEventForTest(binding, 1001, runtimeRaidDungeonSelected)
if err := service.applyRuntimeRaidDungeonLifecycleEvent(selected); err != nil {
t.Fatalf("selected event: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(binding, 1001, runtimeRaidDungeonCompleted)); !errors.Is(err, errRuntimeRaidDungeonEventOrder) {
t.Fatalf("complete before load error=%v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(binding, 1001, runtimeRaidDungeonLoaded)); err != nil {
t.Fatalf("loaded event: %v", err)
}
completed := runtimeRaidDungeonEventForTest(binding, 1001, runtimeRaidDungeonCompleted)
if err := service.applyRuntimeRaidDungeonLifecycleEvent(completed); err != nil {
t.Fatalf("completed event: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(completed); err != nil {
t.Fatalf("duplicate completed event: %v", err)
}
state := service.raids[raidKey]
if state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
t.Fatalf("map completion advanced phase automatically: status=%d phase=%d", state.Lifecycle.Status, state.Lifecycle.Phase)
}
if got := runtimeRaidCompletedMapIDs(*state, runtimeRaidDungeonPhaseOne); len(got) != 1 || got[0] != 210 {
t.Fatalf("completed maps=%v", got)
}
if _, err := service.beginRuntimeRaidDungeon(1002, 210, 21); !errors.Is(err, errRuntimeRaidDungeonCompleted) {
t.Fatalf("completed map begin error=%v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(binding, 1001, runtimeRaidDungeonAbandoned)); !errors.Is(err, errRuntimeRaidDungeonEventOrder) {
t.Fatalf("abandon completed token error=%v", err)
}
retry, err := service.beginRuntimeRaidDungeon(1001, 211, 22)
if err != nil {
t.Fatalf("begin retryable map: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(retry, 1001, runtimeRaidDungeonAbandoned)); err != nil {
t.Fatalf("abandon selected map: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(retry, 1001, runtimeRaidDungeonAbandoned)); err != nil {
t.Fatalf("duplicate abandon: %v", err)
}
if _, err := service.beginRuntimeRaidDungeon(1001, 211, 22); !errors.Is(err, errRuntimeRaidDungeonToken) {
t.Fatalf("reused closed token error=%v", err)
}
if _, err := service.beginRuntimeRaidDungeon(1001, 211, 23); err != nil {
t.Fatalf("new token after abandon: %v", err)
}
}
func TestRuntimeRaidExplicitTwoPhaseMovieTimerRewardAndFinalCleanup(t *testing.T) {
start := time.Date(2026, 8, 6, 2, 0, 0, 0, time.UTC)
service, raidKey := newRuntimeRaidLifecycleService(start)
config := runtimeRaidBridgeTestAntonConfig()
heart := config.Phases[1].Dungeons[2]
heart.InitState = raid.AntonDungeonOpen
heart.HP = 1
config.Phases[1].Dungeons = []raid.AntonDungeonInfo{heart}
service.raids[raidKey].AntonConfig = config
// The phase pays the members its clears registered, so this fixture carries the
// registration a reached settlement implies (runtimeRaidRewardEligibleMembersLocked).
seedRuntimeRaidPhaseClearsForTest(&service.raids[raidKey].Lifecycle, 1001, 1002)
deadline := start.Add(20 * time.Minute)
state, err := service.setRuntimeRaidPhaseDeadline(raidKey, runtimeRaidDungeonPhaseOne, deadline)
if err != nil {
t.Fatalf("set phase-one deadline: %v", err)
}
if got := runtimeRaidPhaseElapsed(state, start.Add(3*time.Minute)); got != 3*time.Minute {
t.Fatalf("elapsed=%s", got)
}
if got := runtimeRaidPhaseRemaining(state, start.Add(3*time.Minute)); got != 17*time.Minute {
t.Fatalf("remaining=%s", got)
}
state, err = service.openRuntimeRaidPhaseMovie(raidKey, runtimeRaidDungeonPhaseOne, start.Add(4*time.Minute))
if err != nil || state.Lifecycle.Status != runtimeRaidLifecycleAwaitingMovie {
t.Fatalf("open phase-one movie state=%+v err=%v", state.Lifecycle, err)
}
if _, advanced, err := service.confirmRuntimeRaidPhaseMovie(1001, runtimeRaidDungeonPhaseOne, start.Add(5*time.Minute)); err != nil || advanced {
t.Fatalf("first movie confirm advanced=%t err=%v", advanced, err)
}
if _, advanced, err := service.confirmRuntimeRaidPhaseMovie(1001, runtimeRaidDungeonPhaseOne, start.Add(5*time.Minute)); err != nil || advanced {
t.Fatalf("duplicate movie confirm advanced=%t err=%v", advanced, err)
}
state, advanced, err := service.confirmRuntimeRaidPhaseMovie(1002, runtimeRaidDungeonPhaseOne, start.Add(6*time.Minute))
if err != nil || !advanced || state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
t.Fatalf("phase-one reward transition advanced=%t state=%+v err=%v", advanced, state.Lifecycle, err)
}
if _, all, err := service.selectRuntimeRaidSettlementCard(1001, runtimeRaidDungeonPhaseOne, raid.RaidRewardCardRequest{Mode: 1, CardIndex: 2}, start.Add(7*time.Minute)); err != nil || all {
t.Fatalf("phase-one first preview all=%t err=%v", all, err)
}
if _, all, err := service.selectRuntimeRaidSettlementCard(1001, runtimeRaidDungeonPhaseOne, raid.RaidRewardCardRequest{Mode: 0, CardIndex: 2}, start.Add(7*time.Minute)); err != nil || all {
t.Fatalf("phase-one first confirm all=%t err=%v", all, err)
}
if _, all, err := service.selectRuntimeRaidSettlementCard(1002, runtimeRaidDungeonPhaseOne, raid.RaidRewardCardRequest{Mode: 1, CardIndex: 3}, start.Add(7*time.Minute)); err != nil || all {
t.Fatalf("phase-one last preview all=%t err=%v", all, err)
}
if _, all, err := service.selectRuntimeRaidSettlementCard(1002, runtimeRaidDungeonPhaseOne, raid.RaidRewardCardRequest{Mode: 0, CardIndex: 3}, start.Add(7*time.Minute)); err != nil || !all {
t.Fatalf("phase-one last confirm all=%t err=%v", all, err)
}
_, phaseOneToken, err := service.reserveRuntimeRaidRewardDelivery(raidKey, runtimeRaidDungeonPhaseOne, start.Add(7*time.Minute))
if err != nil || phaseOneToken == 0 {
t.Fatalf("phase-one reward reservation token=%#x err=%v", phaseOneToken, err)
}
if _, err := service.markRuntimeRaidRewardDeliveryCommitted(raidKey, phaseOneToken, start.Add(7*time.Minute)); err != nil {
t.Fatalf("phase-one durable reward commit: %v", err)
}
state, timer, err := service.enterRuntimeRaidStandby(raidKey, runtimeRaidDungeonPhaseOne, start.Add(7*time.Minute))
if err != nil || state.Lifecycle.Status != runtimeRaidLifecycleStandby || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne || timer.Value2 != 600 {
t.Fatalf("phase-one standby state=%+v timer=%+v err=%v", state.Lifecycle, timer, err)
}
state, err = service.activateRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseTwo, start.Add(8*time.Minute), true)
if err != nil || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo {
t.Fatalf("phase-two activation state=%+v err=%v", state.Lifecycle, err)
}
// A phase pays exactly the members its clears registered, and activating it cleared
// the phase-one counters: both members are in the phase-two roster this fixture
// settles (1001 clears 220 below, 1002 confirms the movie).
seedRuntimeRaidPhaseClearsForTest(&service.raids[raidKey].Lifecycle, 1001, 1002)
if _, err := service.beginRuntimeRaidDungeon(1001, 210, 30); !errors.Is(err, errRuntimeRaidPhaseMismatch) {
t.Fatalf("old phase map error=%v", err)
}
phaseTwoBinding, err := service.beginRuntimeRaidDungeon(1001, 220, 31)
if err != nil {
t.Fatalf("begin phase-two map: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(phaseTwoBinding, 1001, runtimeRaidDungeonLoaded)); err != nil {
t.Fatalf("phase-two loaded: %v", err)
}
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonEventForTest(phaseTwoBinding, 1001, runtimeRaidDungeonCompleted)); err != nil {
t.Fatalf("phase-two completed: %v", err)
}
if _, err := service.openRuntimeRaidPhaseMovie(raidKey, runtimeRaidDungeonPhaseTwo, start.Add(10*time.Minute)); err != nil {
t.Fatalf("open phase-two movie: %v", err)
}
if _, _, err := service.confirmRuntimeRaidPhaseMovie(1001, runtimeRaidDungeonPhaseTwo, start.Add(11*time.Minute)); err != nil {
t.Fatalf("phase-two first movie confirm: %v", err)
}
state, advanced, err = service.confirmRuntimeRaidPhaseMovie(1002, runtimeRaidDungeonPhaseTwo, start.Add(12*time.Minute))
if err != nil || !advanced || state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards {
t.Fatalf("reward transition advanced=%t state=%+v err=%v", advanced, state.Lifecycle, err)
}
if _, all, err := service.confirmRuntimeRaidRewardSelection(1001, 0, 1, 2, start.Add(14*time.Minute)); err != nil || all {
t.Fatalf("first reward selection all=%t err=%v", all, err)
}
if _, all, err := service.confirmRuntimeRaidRewardSelection(1001, 0, 1, 2, start.Add(14*time.Minute)); err != nil || all {
t.Fatalf("duplicate reward selection all=%t err=%v", all, err)
}
if _, all, err := service.confirmRuntimeRaidRewardSelection(1001, 0, 0, 2, start.Add(14*time.Minute)); err != nil || all {
t.Fatalf("first reward confirmation all=%t err=%v", all, err)
}
if _, err := service.markRuntimeRaidRewardDeliveryCommitted(raidKey, 1, start.Add(15*time.Minute)); !errors.Is(err, errRuntimeRaidRewardToken) {
t.Fatalf("early durable reward commit error=%v", err)
}
if _, all, err := service.confirmRuntimeRaidRewardSelection(1002, 0, 1, 3, start.Add(16*time.Minute)); err != nil || all {
t.Fatalf("last reward preview all=%t err=%v", all, err)
}
if _, all, err := service.confirmRuntimeRaidRewardSelection(1002, 0, 0, 3, start.Add(16*time.Minute)); err != nil || !all {
t.Fatalf("last reward selection all=%t err=%v", all, err)
}
_, rewardToken, err := service.reserveRuntimeRaidRewardDelivery(raidKey, runtimeRaidDungeonPhaseTwo, start.Add(16*time.Minute))
if err != nil || rewardToken == 0 {
t.Fatalf("phase-two reward reservation token=%#x err=%v", rewardToken, err)
}
if _, err := service.finalizeRuntimeRaid(raidKey, rewardToken, start.Add(17*time.Minute)); !errors.Is(err, errRuntimeRaidRewardNotReady) {
t.Fatalf("cleanup before durable reward commit error=%v", err)
}
if _, err := service.markRuntimeRaidRewardDeliveryCommitted(raidKey, rewardToken, start.Add(18*time.Minute)); err != nil {
t.Fatalf("durable reward commit: %v", err)
}
finished, err := service.finalizeRuntimeRaid(raidKey, rewardToken, start.Add(19*time.Minute))
if err != nil {
t.Fatalf("final cleanup: %v", err)
}
if finished.Lifecycle.Status != runtimeRaidLifecycleFinished || finished.Lifecycle.FinishedAt.IsZero() {
t.Fatalf("finished lifecycle=%+v", finished.Lifecycle)
}
if _, exists := service.raids[raidKey]; exists {
t.Fatalf("global raid retained after confirmed durable reward delivery")
}
}
func TestRuntimeRaidSingleRouteAdvancesNativeMapGraph(t *testing.T) {
start := time.Date(2026, 8, 6, 7, 0, 0, 0, time.UTC)
routes := []struct {
typ byte
maps []int
}{
{typ: 0, maps: []int{5314, 5317, 5322, 5319, 5320}},
{typ: 3, maps: []int{5314, 5315, 5318, 5319, 5320}},
{typ: 6, maps: []int{5314, 5316, 5321, 5319, 5320}},
}
for _, route := range routes {
config := runtimeRaidBridgeTestAntonConfig()
config.SingleMode = &raid.AntonSingleModeConfig{
Name: "single",
TimeLimitSeconds: 2100,
Phase: raid.AntonPhase{Dungeons: []raid.AntonDungeonInfo{
{Index: 5314, InitState: raid.AntonDungeonOpen, HP: 1, PartyMax: 0},
{Index: 5315, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5316, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5317, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5318, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5321, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5322, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5319, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
{Index: 5320, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 0},
}},
}
lifecycle, err := newRuntimeRaidLifecycleStateFromAntonModeAndType(config, start, true, route.typ)
if err != nil {
t.Fatalf("route %d lifecycle: %v", route.typ, err)
}
member := runtimeRaidMemberState{CharID: 1001, Name: "solo", GroupIndex: 1, SlotOrder: 0}
const raidKey = uint32(0x00d00001)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameplayTimers: &runtimeRaidPhaseTestQueue{now: start},
raids: map[uint32]*runtimeRaidState{raidKey: {
RaidKey: raidKey, LeaderID: member.CharID, TailFlag: 1, SingleRaidType: route.typ,
AntonConfig: config, Members: []runtimeRaidMemberState{member}, FrozenMembers: []runtimeRaidMemberState{member},
Started: true, EntryCostPaid: true, StartAttempt: 1, Lifecycle: lifecycle,
}},
}
active, err := service.activateRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, lifecycle.ReadyDeadline, false)
if err != nil {
t.Fatalf("route %d activate: %v", route.typ, err)
}
if got := active.Lifecycle.PhaseDeadline.Sub(active.Lifecycle.PhaseStartedAt); got != 2100*time.Second {
t.Fatalf("route %d timeout=%s", route.typ, got)
}
for index, mapID := range route.maps {
binding, err := service.beginRuntimeRaidDungeon(member.CharID, mapID, uint64(index+1))
if err != nil {
t.Fatalf("route %d map %d begin: %v", route.typ, mapID, err)
}
for _, kind := range []runtimeRaidDungeonLifecycleKind{runtimeRaidDungeonSelected, runtimeRaidDungeonLoaded, runtimeRaidDungeonCompleted} {
if err := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonLifecycleEvent{
Kind: kind, RaidKey: binding.RaidKey, Phase: binding.Phase, MapID: binding.MapID,
LifecycleToken: binding.LifecycleToken, CharacterID: member.CharID,
}); err != nil {
t.Fatalf("route %d map %d %s: %v", route.typ, mapID, kind, err)
}
}
}
state := service.raids[raidKey]
if state == nil || !runtimeRaidPhaseObjectivesComplete(*state, runtimeRaidDungeonPhaseOne) {
t.Fatalf("route %d did not complete final map: %+v", route.typ, state)
}
if _, err := service.openRuntimeRaidPhaseMovie(raidKey, runtimeRaidDungeonPhaseOne, start.Add(time.Minute)); err != nil {
t.Fatalf("route %d open movie: %v", route.typ, err)
}
}
}
func TestRuntimeRaidLukeSingleRoutesAdvanceOneNativeObjectiveAtATime(t *testing.T) {
start := time.Date(2026, 8, 18, 8, 0, 0, 0, time.UTC)
luke := runtimeRaidBridgeTestLukeConfig()
routes := []struct {
typ byte
maps []int
}{
{typ: runtimeRaidLukeLightRoute, maps: []int{5323, 5324, 5325, 5326, 5327, 5328}},
{typ: runtimeRaidLukeDarkRoute, maps: []int{5329, 5330, 5331, 5332, 5333, 5334}},
}
for _, route := range routes {
lifecycle, err := newRuntimeRaidLifecycleStateFromModeAndType(luke.Raid, runtimeRaidOwnerLuke, start, true, route.typ)
if err != nil {
t.Fatalf("route %d lifecycle: %v", route.typ, err)
}
if got := lifecycle.Objectives[route.maps[0]].State; got != raid.AntonDungeonOpen {
t.Fatalf("route %d first state=%v want open", route.typ, got)
}
if got := lifecycle.Objectives[route.maps[1]].State; got != raid.AntonDungeonClosed {
t.Fatalf("route %d second state=%v want closed", route.typ, got)
}
member := runtimeRaidMemberState{CharID: 1001, Name: "solo", GroupIndex: 1, SlotOrder: 0}
raidKey := uint32(0x00200000) | uint32(route.typ)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameplayTimers: &runtimeRaidPhaseTestQueue{now: lifecycle.ReadyDeadline},
raids: map[uint32]*runtimeRaidState{raidKey: {
RaidKey: raidKey, LeaderID: member.CharID, RaidMode: runtimeRaidOwnerLuke,
TailFlag: 1, SingleRaidType: route.typ, LukeConfig: luke,
Members: []runtimeRaidMemberState{member}, FrozenMembers: []runtimeRaidMemberState{member},
Started: true, EntryCostPaid: true, StartAttempt: 1, Lifecycle: lifecycle,
}},
}
active, err := service.activateRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, lifecycle.ReadyDeadline, false)
if err != nil {
t.Fatalf("route %d activate: %v", route.typ, err)
}
if got := active.Lifecycle.PhaseDeadline.Sub(active.Lifecycle.PhaseStartedAt); got != 1800*time.Second {
t.Fatalf("route %d timeout=%s want=30m", route.typ, got)
}
for index, mapID := range route.maps {
binding, beginErr := service.beginRuntimeRaidDungeon(member.CharID, mapID, uint64(index+1))
if beginErr != nil {
t.Fatalf("route %d map %d begin: %v", route.typ, mapID, beginErr)
}
for _, kind := range []runtimeRaidDungeonLifecycleKind{runtimeRaidDungeonSelected, runtimeRaidDungeonLoaded, runtimeRaidDungeonCompleted} {
if eventErr := service.applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonLifecycleEvent{
Kind: kind, RaidKey: binding.RaidKey, Phase: binding.Phase, MapID: binding.MapID,
LifecycleToken: binding.LifecycleToken, CharacterID: member.CharID,
}); eventErr != nil {
t.Fatalf("route %d map %d %s: %v", route.typ, mapID, kind, eventErr)
}
}
state := service.raids[raidKey]
if got := state.Lifecycle.Objectives[mapID].State; got != raid.AntonDungeonHide {
t.Fatalf("route %d completed map %d state=%v want hidden", route.typ, mapID, got)
}
if index+1 < len(route.maps) {
if got := state.Lifecycle.Objectives[route.maps[index+1]].State; got != raid.AntonDungeonOpen {
t.Fatalf("route %d next map %d state=%v want open", route.typ, route.maps[index+1], got)
}
}
if index+2 < len(route.maps) {
if got := state.Lifecycle.Objectives[route.maps[index+2]].State; got != raid.AntonDungeonClosed {
t.Fatalf("route %d preview map %d state=%v want closed", route.typ, route.maps[index+2], got)
}
}
}
if state := service.raids[raidKey]; state == nil || !runtimeRaidPhaseObjectivesComplete(*state, runtimeRaidDungeonPhaseOne) {
t.Fatalf("route %d did not complete terminal objective: %+v", route.typ, state)
}
}
}
func TestRuntimeRaidDungeonReservationIsConcurrentAndSingleOwner(t *testing.T) {
service, raidKey := newRuntimeRaidLifecycleService(time.Now().UTC())
const contenders = 32
var successes atomic.Int32
var wait sync.WaitGroup
wait.Add(contenders)
for i := 0; i < contenders; i++ {
go func(token uint64) {
defer wait.Done()
if binding, err := service.beginRuntimeRaidDungeon(1001, 212, token); err == nil && binding.RaidKey == raidKey {
successes.Add(1)
}
}(uint64(i + 1))
}
wait.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful concurrent reservations=%d, want 1", got)
}
state := service.raids[raidKey]
if len(state.Lifecycle.GroupOccupancy) != 1 || len(state.Lifecycle.Dungeons) != 1 {
t.Fatalf("occupancy=%+v dungeons=%+v", state.Lifecycle.GroupOccupancy, state.Lifecycle.Dungeons)
}
}
// The attack window expiring does not tear the raid down here: 86JP marks it failed
// (state 4 / argument 1) and hands it to a ten-second remover, so the members still
// get their result screen (RaidHandler.Runtime.cs:592-762). The release itself is
// removeRuntimeRaidFailedPhase.
func TestRuntimeRaidPhaseExpiryRequiresDeadlineAndFailsBeforeRelease(t *testing.T) {
start := time.Date(2026, 8, 6, 3, 0, 0, 0, time.UTC)
service, raidKey := newRuntimeRaidLifecycleService(start)
if _, err := service.expireRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, start.Add(time.Minute)); !errors.Is(err, errRuntimeRaidLifecycleState) {
t.Fatalf("expiry without deadline error=%v", err)
}
deadline := start.Add(10 * time.Minute)
if _, err := service.setRuntimeRaidPhaseDeadline(raidKey, runtimeRaidDungeonPhaseOne, deadline); err != nil {
t.Fatalf("set deadline: %v", err)
}
if _, err := service.expireRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, deadline.Add(-time.Second)); !errors.Is(err, errRuntimeRaidLifecycleState) {
t.Fatalf("early expiry error=%v", err)
}
failed, err := service.expireRuntimeRaidPhase(raidKey, runtimeRaidDungeonPhaseOne, deadline)
if err != nil {
t.Fatalf("expire at deadline: %v", err)
}
if failed.Lifecycle.Status != runtimeRaidLifecycleFailed || !failed.Lifecycle.FailedAt.Equal(deadline) {
t.Fatalf("failed lifecycle=%+v", failed.Lifecycle)
}
if _, exists := service.raids[raidKey]; !exists {
t.Fatal("the failed raid was released before its result window")
}
service.removeRuntimeRaidFailedPhase(raidKey, runtimeRaidDungeonPhaseOne)
if _, exists := service.raids[raidKey]; exists {
t.Fatal("the failed raid was not cleaned up")
}
}
func newRuntimeRaidLifecycleService(start time.Time) (*Service, uint32) {
const raidKey = uint32(0x00c803e9)
members := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 2, SlotOrder: 0},
}
state := &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(start),
}
return &Service{raids: map[uint32]*runtimeRaidState{raidKey: state}}, raidKey
}
func runtimeRaidDungeonEventForTest(binding runtimeRaidDungeonBinding, characterID uint16, kind runtimeRaidDungeonLifecycleKind) runtimeRaidDungeonLifecycleEvent {
return runtimeRaidDungeonLifecycleEvent{
Kind: kind,
RaidKey: binding.RaidKey,
Phase: binding.Phase,
MapID: binding.MapID,
LifecycleToken: binding.LifecycleToken,
CharacterID: characterID,
}
}
func runtimeRaidBridgeTestAntonConfig() *raid.AntonConfig {
return &raid.AntonConfig{
PhaseMax: 2,
MemberMax: 20,
StartMinimumMembers: 1,
SingleStartMinimumMembers: 1,
StartDelaySeconds: 3,
PhaseTimeoutSeconds: []int{2400, 2400},
PhaseBreakSeconds: 600,
MovieEndSeconds: []int{20, 20, 20, 20},
EntryCost: raid.AntonEntryCost{ItemID: runtimeRaidEntryItemID, Count: runtimeRaidEntryCount},
Phases: []raid.AntonPhase{
{Dungeons: []raid.AntonDungeonInfo{
{Index: 210, InitState: raid.AntonDungeonOpen, HP: 4, PartyMax: 5},
{Index: 211, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 212, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 213, InitState: raid.AntonDungeonHide, HP: 2, PartyMax: 5},
{Index: 214, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 215, InitState: raid.AntonDungeonHide, HP: 2, PartyMax: 5},
{Index: 216, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 1},
}},
{Dungeons: []raid.AntonDungeonInfo{
{Index: 218, InitState: raid.AntonDungeonOpen, HP: 1, PartyMax: 5},
{Index: 219, InitState: raid.AntonDungeonOpen, HP: 1, PartyMax: 5},
{Index: 220, InitState: raid.AntonDungeonHide, HP: 5, PartyMax: 5},
{Index: 221, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 222, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 223, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
{Index: 224, InitState: raid.AntonDungeonHide, HP: 1, PartyMax: 5},
}},
},
}
}
func assertRuntimeRaidReadyTimer(t *testing.T, stream []byte, _ uint32) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidStateMsgID ||
!bytes.Equal(packet.Body, raid.BuildRaidStateBody(raid.RaidState{State: runtimeRaidPrepareState, Argument: 0})) {
t.Fatalf("ready prepare state header=%+v body=%x", packet.Header, packet.Body)
}
packet, rest = splitGameServerUpperPacket(t, rest)
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidTimerMsgID || len(packet.Body) != 13 {
t.Fatalf("ready timer header=%+v body=%x", packet.Header, packet.Body)
}
if packet.Body[0] != 0 || binary.LittleEndian.Uint32(packet.Body[1:5]) != runtimeRaidTopTimerKey ||
binary.LittleEndian.Uint32(packet.Body[5:9]) == 0 || binary.LittleEndian.Uint32(packet.Body[9:13]) != 3 {
t.Fatalf("ready timer body=%x", packet.Body)
}
remain, rest := splitGameServerUpperPacket(t, rest)
if remain.Header.Classification != 0 || remain.Header.MsgID != raid.RaidRemainTimeMsgID || len(remain.Body) != 5 ||
remain.Body[0] != 0 || binary.LittleEndian.Uint32(remain.Body[1:]) != 3 {
t.Fatalf("ready remaining header=%+v body=%x", remain.Header, remain.Body)
}
prepare, rest := splitGameServerUpperPacket(t, rest)
if prepare.Header.Classification != 0 || prepare.Header.MsgID != raid.RaidPrepareStartMsgID || len(prepare.Body) != 0 {
t.Fatalf("ready prepare header=%+v body=%x", prepare.Header, prepare.Body)
}
return rest
}
func assertRuntimeRaidBuffRegistrationPrefix(t *testing.T, stream []byte, state runtimeRaidState) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, stream)
wantBody := mustRuntimeRaidBuffRegistrationSnapshotBody(t, state)
if packet.Header.Classification != 0 || packet.Header.MsgID != raid.RaidBuffMsgID ||
!bytes.Equal(packet.Body, wantBody) {
t.Fatalf("raid buff header=%+v body=%x want=%x", packet.Header, packet.Body, wantBody)
}
return rest
}
// assertRuntimeRaidEntryCounterRefresh consumes the tail a successful start
// appends AFTER the pinned handshake: the op1303 account rows, then one op1206
// record per raid mode (Anton 181, Luke 282, Fiend War 2449).
//
// The client never queries these counters -- its op1206 handler writes them onto
// the per-mode window element and the window only re-renders that stored state --
// so this tail is the only thing that makes 帐号日入场数 / 帐号周入场场数 /
// 角色周入场次数 move at the moment a start charges them, instead of at the next
// relog. Pinning the event ids here keeps that contract honest.
func assertRuntimeRaidEntryCounterRefresh(t *testing.T, stream []byte) []byte {
t.Helper()
daily, rest := splitGameServerUpperPacket(t, stream)
if daily.Header.Classification != 0 || daily.Header.MsgID != currentRaidDailyAvailabilityMsgID {
t.Fatalf("entry counter daily header=%+v body=%x", daily.Header, daily.Body)
}
for _, eventID := range []uint32{currentAntonRaidEventID, currentLukeRaidEventID, currentFiendWarEventID} {
packet, next := splitGameServerUpperPacket(t, rest)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentSpendTimeProgressMsgID {
t.Fatalf("entry counter weekly event=%d header=%+v", eventID, packet.Header)
}
if len(packet.Body) < 5 || binary.LittleEndian.Uint32(packet.Body[:4]) != eventID || packet.Body[4] != 0 {
t.Fatalf("entry counter weekly event=%d body=%x", eventID, packet.Body)
}
rest = next
}
return rest
}