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

284 lines
11 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"testing"
dnfrental "longheng.io/server/internal/modules/dnf/rental"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
)
func TestSendSuitableDungeonLuckyStarStateSendsCommittedStateOnce(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{dnfrental.PointMetadataKey: "1"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{
conn: connection,
accountID: "account-1",
selectedCharacterID: 19,
}
runtime := &runtimeDungeonState{
Dungeon: worldmap.Dungeon{ID: 244},
Character: dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 90,
},
settlementLuckyStarResolved: true,
settlementLuckyStarRefreshPending: true,
settlementLuckyStarBefore: 0,
settlementLuckyStarApplied: 1,
settlementLuckyStarAfter: 1,
settlementLuckyStarLimit: 400,
}
service.sendSuitableDungeonLuckyStarState(session, runtime)
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if packet.Header.Classification != 1 || packet.Header.MsgID != currentRentalPointResultMsgID {
t.Fatalf("lucky-star notice class=%d msg=%d want class1/op%d", packet.Header.Classification, packet.Header.MsgID, currentRentalPointResultMsgID)
}
if len(packet.Body) != 13 || packet.Body[0] != 1 ||
binary.LittleEndian.Uint32(packet.Body[1:5]) != currentRentalPointSourceDungeonClear ||
binary.LittleEndian.Uint32(packet.Body[5:9]) != 0 ||
binary.LittleEndian.Uint32(packet.Body[9:13]) != 1 {
t.Fatalf("lucky-star notice body=%x want success,type2,not-capped,points1", packet.Body)
}
packet, rest = splitGameServerUpperPacket(t, rest)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentRentalStateMsgID {
t.Fatalf("lucky-star refresh class=%d msg=%d want class0/op%d", packet.Header.Classification, packet.Header.MsgID, currentRentalStateMsgID)
}
if len(rest) != 0 {
next, _ := splitGameServerUpperPacket(t, rest)
t.Fatalf("unexpected lucky-star trailing packet msg=%d", next.Header.MsgID)
}
if len(packet.Body) != 8 || binary.LittleEndian.Uint32(packet.Body[:4]) != 1 ||
binary.LittleEndian.Uint32(packet.Body[4:8]) != 0 {
t.Fatalf("rental state body=%x want points=1 active_count=0", packet.Body)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
points, err := dnfrental.Points(account)
if err != nil || points != 1 {
t.Fatalf("committed rental points=%d err=%v", points, err)
}
written := connection.write.Len()
service.sendSuitableDungeonLuckyStarState(session, runtime)
account, _, err = repositories.Account.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
points, err = dnfrental.Points(account)
if err != nil || points != 1 || connection.write.Len() != written {
t.Fatalf("replay points=%d bytes_before=%d bytes_after=%d err=%v", points, written, connection.write.Len(), err)
}
}
func TestSendSuitableDungeonLuckyStarStateRetriesDeferredActiveAssetLoad(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{dnfrental.PointMetadataKey: "1"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 90,
}); err != nil {
t.Fatal(err)
}
baseInventory := repositories.Inventory
repositories.Inventory = rentalFailingInventoryLoadRepository{
InventoryRepository: baseInventory,
err: errors.New("forced rental inventory read failure"),
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{
conn: connection,
accountID: "account-1",
selectedCharacterID: 19,
selectedRentalWalletStateSent: true,
}
runtime := &runtimeDungeonState{
Dungeon: worldmap.Dungeon{ID: 244},
settlementLuckyStarResolved: true,
settlementLuckyStarRefreshPending: true,
settlementLuckyStarNoticeSent: true,
settlementLuckyStarBefore: 0,
settlementLuckyStarApplied: 1,
settlementLuckyStarAfter: 1,
settlementLuckyStarLimit: 400,
}
service.sendSuitableDungeonLuckyStarState(session, runtime)
if connection.write.Len() != 0 {
t.Fatalf("deferred active-asset load emitted op985: %x", connection.write.Bytes())
}
if runtime.settlementLuckyStarStateSent || session.selectedRentalWalletStateSent {
t.Fatalf("deferred active-asset load closed retry gates runtime=%t session=%t",
runtime.settlementLuckyStarStateSent, session.selectedRentalWalletStateSent)
}
repositories.Inventory = baseInventory
service.sendSuitableDungeonLuckyStarState(session, runtime)
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.Classification != 0 || packet.Header.MsgID != currentRentalStateMsgID {
t.Fatalf("retry packet header=%+v trailing=%x", packet.Header, rest)
}
if !runtime.settlementLuckyStarStateSent || !session.selectedRentalWalletStateSent {
t.Fatalf("successful retry left gates open runtime=%t session=%t",
runtime.settlementLuckyStarStateSent, session.selectedRentalWalletStateSent)
}
}
func TestRecommendedDungeonSettlementRefreshesRentalStateAfterResultChain(t *testing.T) {
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
repositories, ok := service.repositoryGroup()
if !ok || repositories.Account == nil {
t.Fatal("settlement fixture has no account repository")
}
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{dnfrental.PointMetadataKey: "1"},
}); err != nil {
t.Fatal(err)
}
runtime.Character.Level = 20
runtime.Dungeon.Metadata.MinimumRequiredLevel.Set = true
runtime.Dungeon.Metadata.MinimumRequiredLevel.Value = 10
runtime.Dungeon.Metadata.BasisLevel.Set = true
runtime.Dungeon.Metadata.BasisLevel.Value = 20
// The domain settlement owns persistence. This test isolates the socket
// order after that commit without requiring a real PVF archive fixture.
runtime.settlementLuckyStarResolved = true
runtime.settlementLuckyStarRefreshPending = true
runtime.settlementLuckyStarBefore = 0
runtime.settlementLuckyStarApplied = 1
runtime.settlementLuckyStarAfter = 1
runtime.settlementLuckyStarLimit = 400
connection.write.Reset()
const clientRankPoint = byte(73)
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, clientRankPoint)
runtime.settlementResultPlan = &plan
request := make([]byte, currentDungeonPlayResultBaseSize)
request[currentDungeonPlayResultClientRankPointOffset] = clientRankPoint
if err := service.handleDungeonSetPlayResult(session, request); err != nil {
t.Fatal(err)
}
want := []struct {
classification byte
msgID uint16
}{
{classification: 0, msgID: currentDungeonPlayResultNoticeMsgID},
{classification: 0, msgID: currentDungeonCharacterStateMsgID},
{classification: 0, msgID: currentDungeonClearRewardMsgID},
{classification: 1, msgID: currentRentalPointResultMsgID},
{classification: 0, msgID: currentRentalStateMsgID},
}
stream := connection.write.Bytes()
for index, expected := range want {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification != expected.classification || packet.Header.MsgID != expected.msgID {
t.Fatalf("packet[%d] class=%d msg=%d want class%d/msg%d", index, packet.Header.Classification, packet.Header.MsgID, expected.classification, expected.msgID)
}
stream = rest
}
if len(stream) != 0 {
packet, _ := splitGameServerUpperPacket(t, stream)
t.Fatalf("unexpected settlement trailing packet msg=%d", packet.Header.MsgID)
}
}
func TestDungeonLuckyStarResultMarksLimitOnlyAtCap(t *testing.T) {
for _, test := range []struct {
name string
before uint32
applied uint32
after uint32
wantLimitFlag uint32
}{
{name: "below cap", before: 398, applied: 1, after: 399, wantLimitFlag: 0},
{name: "reaches cap", before: 399, applied: 1, after: 400, wantLimitFlag: 1},
{name: "already capped", before: 400, applied: 0, after: 400, wantLimitFlag: 1},
} {
t.Run(test.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, selectedCharacterID: 19}
runtime := &runtimeDungeonState{
Dungeon: worldmap.Dungeon{ID: 244},
settlementLuckyStarResolved: true,
settlementLuckyStarRefreshPending: true,
settlementLuckyStarBefore: test.before,
settlementLuckyStarApplied: test.applied,
settlementLuckyStarAfter: test.after,
settlementLuckyStarLimit: 400,
}
// Stop after the native notice. The state refresh has a repository
// dependency which is covered by the end-to-end test above.
runtime.settlementLuckyStarStateSent = true
service.sendSuitableDungeonLuckyStarState(session, runtime)
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || packet.Header.Classification != 1 || packet.Header.MsgID != currentRentalPointResultMsgID {
t.Fatalf("notice header=%+v trailing=%d", packet.Header, len(rest))
}
if len(packet.Body) != 13 || packet.Body[0] != 1 ||
binary.LittleEndian.Uint32(packet.Body[1:5]) != currentRentalPointSourceDungeonClear ||
binary.LittleEndian.Uint32(packet.Body[5:9]) != test.wantLimitFlag ||
binary.LittleEndian.Uint32(packet.Body[9:13]) != test.after {
t.Fatalf("notice body=%x", packet.Body)
}
})
}
}