服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
378 lines
16 KiB
Go
378 lines
16 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
"longheng.io/server/internal/platform/timequeue"
|
|
)
|
|
|
|
func TestRuntimeRaidCreate664ReusesIdenticalRequestAndRejectsMembershipConflicts(t *testing.T) {
|
|
service := &Service{raids: make(map[uint32]*runtimeRaidState)}
|
|
channel := channelcatalog.Channel{ID: 200, Type: 23}
|
|
leader := &gameSession{channel: channel}
|
|
service.bindGameSessionCharacter(leader, 1001)
|
|
req := raid.InfoRequest{RouteOrRaidType: 1, NameBytes: []byte("anton"), TailFlag: 5}
|
|
|
|
created, accepted := service.createRuntimeRaid(leader, 1001, req)
|
|
if !accepted || created.RaidKey == 0 || len(service.raids) != 1 {
|
|
t.Fatalf("first 664 accepted=%t state=%+v raid_count=%d", accepted, created, len(service.raids))
|
|
}
|
|
replayed, accepted := service.createRuntimeRaid(leader, 1001, req)
|
|
if !accepted || replayed.RaidKey != created.RaidKey || len(service.raids) != 1 {
|
|
t.Fatalf("identical 664 accepted=%t key=%#x want=%#x raid_count=%d", accepted, replayed.RaidKey, created.RaidKey, len(service.raids))
|
|
}
|
|
|
|
different := req
|
|
different.NameBytes = []byte("other")
|
|
if _, accepted = service.createRuntimeRaid(leader, 1001, different); accepted || len(service.raids) != 1 {
|
|
t.Fatalf("different 664 accepted=%t raid_count=%d", accepted, len(service.raids))
|
|
}
|
|
|
|
service.raids[created.RaidKey].Members = append(service.raids[created.RaidKey].Members,
|
|
runtimeRaidMemberState{CharID: 1002, GroupIndex: 1, SlotOrder: 1})
|
|
member := &gameSession{channel: channel}
|
|
service.bindGameSessionCharacter(member, 1002)
|
|
if _, accepted = service.createRuntimeRaid(member, 1002, req); accepted || len(service.raids) != 1 {
|
|
t.Fatalf("member-owned 664 accepted=%t raid_count=%d", accepted, len(service.raids))
|
|
}
|
|
|
|
otherLeader := &gameSession{channel: channel}
|
|
service.bindGameSessionCharacter(otherLeader, 2001)
|
|
storeRuntimePartyState(otherLeader, alignedcmd.PartyState{
|
|
PartyID: 2001,
|
|
UserID: 2001,
|
|
MaxMembers: 4,
|
|
Members: []alignedcmd.PartyMemberState{
|
|
{UserID: 2001},
|
|
{UserID: 1002},
|
|
},
|
|
})
|
|
if _, accepted = service.createRuntimeRaid(otherLeader, 2001, req); accepted || len(service.raids) != 1 {
|
|
t.Fatalf("party containing existing raid member accepted=%t raid_count=%d", accepted, len(service.raids))
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRaidCreate664PreservesUnfinishedRaidWhenModeDiffers(t *testing.T) {
|
|
service := &Service{raids: make(map[uint32]*runtimeRaidState)}
|
|
channel := channelcatalog.Channel{ID: 200, Type: 23}
|
|
leader := &gameSession{channel: channel}
|
|
service.bindGameSessionCharacter(leader, 1001)
|
|
|
|
single := raid.InfoRequest{RouteOrRaidType: 1, NameBytes: []byte("anton"), TailFlag: 1}
|
|
created, accepted := service.createRuntimeRaid(leader, 1001, single)
|
|
if !accepted || created.TailFlag != 1 || len(created.Members) != 1 {
|
|
t.Fatalf("single create accepted=%t state=%+v", accepted, created)
|
|
}
|
|
|
|
normal := single
|
|
normal.TailFlag = 0
|
|
if _, accepted = service.createRuntimeRaid(leader, 1001, normal); accepted {
|
|
t.Fatal("normal switch replaced the unfinished single raid")
|
|
}
|
|
if len(service.raids) != 1 {
|
|
t.Fatalf("mode mismatch raid count=%d want=1", len(service.raids))
|
|
}
|
|
if current := service.raids[created.RaidKey]; current == nil || current.TailFlag != 1 || current.RaidKey != created.RaidKey {
|
|
t.Fatalf("unfinished raid was not preserved: current=%+v created=%+v", current, created)
|
|
}
|
|
}
|
|
|
|
func TestApplyRuntimeRaidPartiesDoesNotCreateOrdinaryPartyForSingleRaid(t *testing.T) {
|
|
service := &Service{raids: make(map[uint32]*runtimeRaidState)}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: connection,
|
|
channel: channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType},
|
|
}
|
|
service.bindGameSessionCharacter(session, 1001)
|
|
member := runtimeRaidMemberState{CharID: 1001, GroupIndex: 1, SlotOrder: 0}
|
|
state := runtimeRaidState{
|
|
RaidKey: 0x00170001,
|
|
LeaderID: 1001,
|
|
TailFlag: 1,
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
Members: []runtimeRaidMemberState{member},
|
|
FrozenMembers: []runtimeRaidMemberState{member},
|
|
}
|
|
|
|
// Seed the stale one-member projection produced by the former start path.
|
|
stale := runtimeRaidGroupPartyState(state, state.Members)
|
|
storeRuntimePartyState(session, stale)
|
|
if managed, restored := service.bootstrapManagedRuntimePartyForSession(session); !restored || len(managed.Members) != 1 {
|
|
t.Fatalf("seed stale single raid party: restored=%t party=%+v", restored, managed)
|
|
}
|
|
connection.write.Reset()
|
|
|
|
if err := service.applyRuntimeRaidParties(state); err != nil {
|
|
t.Fatalf("apply single raid parties: %v", err)
|
|
}
|
|
if partyState := runtimePartyStateSnapshot(session); partyState.PartyID > 0 || len(partyState.Members) != 0 {
|
|
t.Fatalf("single raid retained ordinary party projection: %+v", partyState)
|
|
}
|
|
if managed, found := service.runtimePartyManagerForService().SnapshotByUser(1001, session.characterGeneration); found {
|
|
t.Fatalf("single raid retained central ordinary party: %+v", managed)
|
|
}
|
|
if connection.write.Len() == 0 {
|
|
t.Fatal("stale single raid party was not cleared from the current client")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRaidStartUsesNormalMinimumAndReplaysWithoutSecondDebitOrPhaseAdvance(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)
|
|
}
|
|
}
|
|
config := runtimeRaidBridgeTestAntonConfig()
|
|
config.StartMinimumMembers = 2
|
|
config.SingleStartMinimumMembers = 1
|
|
config.StartDelaySeconds = 3600
|
|
now := time.Date(2026, 8, 6, 10, 0, 0, 0, time.UTC)
|
|
queue := &raidStartRecordingTimeQueue{now: now}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
raids: make(map[uint32]*runtimeRaidState),
|
|
raidAnton: config,
|
|
gameplayTimers: queue,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
channel := channelcatalog.Channel{ID: 200, Type: 23}
|
|
leaderConn := &bufferConn{}
|
|
memberConn := &bufferConn{}
|
|
leader := &gameSession{conn: leaderConn, channel: channel}
|
|
member := &gameSession{conn: memberConn, channel: channel}
|
|
service.bindGameSessionCharacter(leader, 1001)
|
|
service.bindGameSessionCharacter(member, 1002)
|
|
raidKey := uint32(200)<<16 | 1001
|
|
// Both members are in the raid waiting area: the start command refuses a roster
|
|
// that is not all there (see raid_start_waiting_area.go). Only the leader is in
|
|
// the roster at this point; the member is appended below from the same area.
|
|
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, leader, testAntonWaitingAreaSpec(t), raidKey)
|
|
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, member, testAntonWaitingAreaSpec(t), raidKey)
|
|
service.raids[raidKey] = &runtimeRaidState{
|
|
RaidKey: raidKey,
|
|
LeaderID: 1001,
|
|
Members: []runtimeRaidMemberState{
|
|
{CharID: 1001, GroupIndex: 1, SlotOrder: 0},
|
|
},
|
|
}
|
|
|
|
if err := service.handleOnlineStartRaid(leader, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatalf("start below normal minimum: %v", err)
|
|
}
|
|
failure, rest := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
|
|
if !bytes.Equal(failure.Body, []byte{0, runtimeRaidStartFailureMinimumRoster}) || len(rest) != 0 {
|
|
t.Fatalf("below-minimum response=% X trailing=%d", failure.Body, len(rest))
|
|
}
|
|
if state := service.raids[raidKey]; state.Started || state.StartPending || state.EntryCostPaid {
|
|
t.Fatalf("single member bypassed normal minimum: %+v", state)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, "1001", 2)
|
|
|
|
service.raids[raidKey].Members = append(service.raids[raidKey].Members,
|
|
runtimeRaidMemberState{CharID: 1002, GroupIndex: 1, SlotOrder: 1})
|
|
leaderConn.write.Reset()
|
|
if err := service.handleOnlineStartRaid(leader, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatalf("start at normal minimum: %v", err)
|
|
}
|
|
ack, _ := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
|
|
if ack.Header.MsgID != uint16(dnfenum.CmdPacketStartRaid) || !bytes.Equal(ack.Body, raid.BuildRaidCommandSuccessBody()) {
|
|
t.Fatalf("successful start ACK msg=%d body=% X", ack.Header.MsgID, ack.Body)
|
|
}
|
|
started := cloneRuntimeRaidState(*service.raids[raidKey])
|
|
if !started.Started || !started.EntryCostPaid || started.Lifecycle.Status != runtimeRaidLifecyclePreparing {
|
|
t.Fatalf("successful start state=%+v", started)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
|
|
assertRuntimeRaidTicketCount(t, repositories, "1002", 1)
|
|
|
|
service.unbindGameSession(member)
|
|
leaderConn.write.Reset()
|
|
memberConn.write.Reset()
|
|
if err := service.handleOnlineStartRaid(leader, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatalf("retry after member disconnect: %v", err)
|
|
}
|
|
retryACK, _ := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
|
|
if !bytes.Equal(retryACK.Body, raid.BuildRaidCommandSuccessBody()) {
|
|
t.Fatalf("retry ACK body=% X", retryACK.Body)
|
|
}
|
|
replayed := service.raids[raidKey]
|
|
if replayed.StartAttempt != started.StartAttempt || !replayed.Lifecycle.StartedAt.Equal(started.Lifecycle.StartedAt) ||
|
|
!replayed.Lifecycle.ReadyDeadline.Equal(started.Lifecycle.ReadyDeadline) {
|
|
t.Fatalf("retry changed committed start: before=%+v after=%+v", started, replayed)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
|
|
assertRuntimeRaidTicketCount(t, repositories, "1002", 1)
|
|
|
|
// While the raid sits in the between-phase standby, the same empty START_RAID
|
|
// is the client's "begin the next phase" command: live 2026-09-16 20:30Z the
|
|
// leader clicked it three times during standby and every answer was a standby
|
|
// replay, so phase two never began. The click must now activate phase two
|
|
// without debiting the entry ticket again, and a second click must not advance
|
|
// anything on top of it.
|
|
standbyStarted := now.Add(10 * time.Minute)
|
|
standbyDeadline := standbyStarted.Add(10 * time.Minute)
|
|
service.raidMu.Lock()
|
|
standby := service.raids[raidKey]
|
|
standby.Lifecycle.Status = runtimeRaidLifecycleStandby
|
|
standby.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
|
|
standby.Lifecycle.ReadyDeadline = time.Time{}
|
|
standby.Lifecycle.StandbyStartedAt = standbyStarted
|
|
standby.Lifecycle.StandbyDeadline = standbyDeadline
|
|
standby.Lifecycle.Revision++
|
|
service.raidMu.Unlock()
|
|
|
|
leaderConn.write.Reset()
|
|
if err := service.handleOnlineStartRaid(leader, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatalf("start during phase standby: %v", err)
|
|
}
|
|
// The next-phase activation broadcasts the raid object and the phase snapshot
|
|
// around the command ACK, so look for the ACK itself instead of the first frame.
|
|
standbyACK := false
|
|
for _, packet := range splitAllCurrentGameServerUpperPackets(t, leaderConn.write.Bytes()) {
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketStartRaid) &&
|
|
bytes.Equal(packet.Body, raid.BuildRaidCommandSuccessBody()) {
|
|
standbyACK = true
|
|
}
|
|
}
|
|
if !standbyACK {
|
|
t.Fatalf("standby start emitted no command ACK stream=%x", leaderConn.write.Bytes())
|
|
}
|
|
afterNextPhase := service.raids[raidKey]
|
|
if afterNextPhase.Lifecycle.Status != runtimeRaidLifecyclePhaseActive ||
|
|
afterNextPhase.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo {
|
|
t.Fatalf("standby start did not begin phase two: %+v", afterNextPhase.Lifecycle)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
|
|
assertRuntimeRaidTicketCount(t, repositories, "1002", 1)
|
|
|
|
// A second click inside the now-active phase two only replays the state.
|
|
activatedPhase := afterNextPhase.Lifecycle.Phase
|
|
activatedStartedAt := afterNextPhase.Lifecycle.PhaseStartedAt
|
|
leaderConn.write.Reset()
|
|
if err := service.handleOnlineStartRaid(leader, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatalf("retry during active phase two: %v", err)
|
|
}
|
|
replayedPhaseTwo := service.raids[raidKey]
|
|
if replayedPhaseTwo.Lifecycle.Phase != activatedPhase ||
|
|
!replayedPhaseTwo.Lifecycle.PhaseStartedAt.Equal(activatedStartedAt) {
|
|
t.Fatalf("second start advanced the active phase: %+v", replayedPhaseTwo.Lifecycle)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, "1001", 1)
|
|
assertRuntimeRaidTicketCount(t, repositories, "1002", 1)
|
|
}
|
|
|
|
func TestRuntimeLukeSingleRaidStartDebitsLukeTicketAndFreezesNativeRoute(t *testing.T) {
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
const characterID = "1001"
|
|
luke := runtimeRaidBridgeTestLukeConfig()
|
|
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
|
CharacterID: characterID,
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:0": {ItemID: int64(luke.Raid.EntryCost.ItemID), Count: 2},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queue := &raidStartRecordingTimeQueue{now: time.Now().UTC()}
|
|
service := &Service{
|
|
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
raids: make(map[uint32]*runtimeRaidState),
|
|
raidLuke: luke,
|
|
gameplayTimers: queue,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: connection,
|
|
channel: channelcatalog.Channel{ID: 241, Type: dnfenum.HiddenRaidType, Name: "luke"},
|
|
}
|
|
service.bindGameSessionCharacter(session, 1001)
|
|
const raidKey = uint32(0x00f10001)
|
|
// The Luke start carries the same waiting-area gate as the Anton one, against
|
|
// the Luke town's own waiting area (see raid_start_waiting_area.go).
|
|
enterRuntimeRaidWaitingAreaPresenceForTest(t, service, session, testLukeWaitingAreaSpec(t), raidKey)
|
|
member := runtimeRaidMemberState{CharID: 1001, Name: "solo", GroupIndex: 1, SlotOrder: 0}
|
|
service.raids[raidKey] = &runtimeRaidState{
|
|
RaidKey: raidKey, LeaderID: member.CharID, RaidMode: runtimeRaidOwnerLuke,
|
|
TailFlag: 1, LukeConfig: luke, Members: []runtimeRaidMemberState{member},
|
|
}
|
|
|
|
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := service.raids[raidKey]
|
|
if state == nil || !state.Started || !state.EntryCostPaid || state.RaidMode != runtimeRaidOwnerLuke ||
|
|
state.LukeConfig != luke || state.AntonConfig != nil || state.Lifecycle.Status != runtimeRaidLifecyclePreparing {
|
|
t.Fatalf("Luke start state=%+v", state)
|
|
}
|
|
wantRoute := runtimeRaidLukeSingleTypeForTime(state.Lifecycle.StartedAt)
|
|
if state.SingleRaidType != wantRoute {
|
|
t.Fatalf("Luke route=%d want=%d at %s", state.SingleRaidType, wantRoute, state.Lifecycle.StartedAt)
|
|
}
|
|
firstMap := runtimeRaidLukeSingleRoute(wantRoute)[0]
|
|
if objective := state.Lifecycle.Objectives[firstMap]; objective.State != raid.AntonDungeonOpen {
|
|
t.Fatalf("Luke first objective=%+v", objective)
|
|
}
|
|
assertRuntimeRaidTicketCount(t, repositories, characterID, 1)
|
|
if len(queue.scheduled) != 1 || queue.scheduled[0] != "raid-ready:15794177:1" {
|
|
t.Fatalf("Luke ready schedules=%v", queue.scheduled)
|
|
}
|
|
}
|
|
|
|
func assertRuntimeRaidTicketCount(t *testing.T, repositories dnfrepo.Group, characterID string, want int64) {
|
|
t.Helper()
|
|
record, found, err := repositories.Inventory.Load(context.Background(), characterID)
|
|
if err != nil || !found {
|
|
t.Fatalf("load ticket inventory %s found=%t err=%v", characterID, found, err)
|
|
}
|
|
got := int64(0)
|
|
if stack, ok := record.Slots["0:0"]; ok {
|
|
got = stack.Count
|
|
}
|
|
if got != want {
|
|
t.Fatalf("ticket inventory %s count=%d want=%d", characterID, got, want)
|
|
}
|
|
}
|
|
|
|
type raidStartRecordingTimeQueue struct {
|
|
now time.Time
|
|
scheduled []string
|
|
}
|
|
|
|
func (q *raidStartRecordingTimeQueue) Now() time.Time { return q.now }
|
|
|
|
func (q *raidStartRecordingTimeQueue) ScheduleAfter(name string, _ time.Duration, _ timequeue.Callback) error {
|
|
q.scheduled = append(q.scheduled, name)
|
|
return nil
|
|
}
|
|
|
|
func (q *raidStartRecordingTimeQueue) RegisterDaily(
|
|
name string,
|
|
_, _ int,
|
|
_ timequeue.Callback,
|
|
) error {
|
|
q.scheduled = append(q.scheduled, name)
|
|
return nil
|
|
}
|
|
|
|
func (q *raidStartRecordingTimeQueue) Cancel(string) bool { return false }
|
|
|
|
func (q *raidStartRecordingTimeQueue) Start(context.Context) {}
|