DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,709 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||||
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
|
||||
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
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 TestHandleDungeonSelectUpperBuildsRuntimeFromPVF(t *testing.T) {
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Level: 20,
|
||||
Stats: map[string]int64{
|
||||
"fatigue": 100, "town_id": 38, "area_id": 1,
|
||||
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: 1, Count: 7},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonChoice: func(limit int) (int, error) {
|
||||
if limit != 1 {
|
||||
t.Fatalf("choice limit = %d, want 1", limit)
|
||||
}
|
||||
return 0, nil
|
||||
},
|
||||
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "dungeon-test",
|
||||
selectedCharacterID: 99,
|
||||
selectPreviewObjectStateSent: true,
|
||||
currentSceneObjectListSent: true,
|
||||
}
|
||||
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
|
||||
|
||||
body := make([]byte, 21)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 700)
|
||||
body[4] = 1
|
||||
frame, err := dnfproto.BuildChannelPacket(
|
||||
uint16(dnfenum.UpperMsgSelectEnter),
|
||||
body,
|
||||
0,
|
||||
dnfproto.DefaultChannelClassification,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build select dungeon upper packet: %v", err)
|
||||
}
|
||||
if err := service.handleGameUpper(session, frame); err != nil {
|
||||
t.Fatalf("handle select dungeon: %v", err)
|
||||
}
|
||||
|
||||
previewRemoval, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if previewRemoval.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
||||
previewRemoval.Header.Classification != 0 ||
|
||||
!bytes.Equal(previewRemoval.Body, buildCurrentSceneOp9ActorRemovalBody(99)) {
|
||||
t.Fatalf("preview removal = header=%+v body=%x", previewRemoval.Header, previewRemoval.Body)
|
||||
}
|
||||
selectAck, rest := splitGameServerUpperPacket(t, rest)
|
||||
if selectAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
|
||||
selectAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
!bytes.Equal(selectAck.Body, upperSuccessBody(buildSelectDungeonBodyForCharacter(99))) {
|
||||
t.Fatalf("select ACK = header=%+v body=%x", selectAck.Header, selectAck.Body)
|
||||
}
|
||||
resourcePacket, rest := splitGameServerUpperPacket(t, rest)
|
||||
if resourcePacket.Header.MsgID != currentDungeonResourceStateMsgID ||
|
||||
resourcePacket.Header.Classification != 0 ||
|
||||
!bytes.Equal(resourcePacket.Body, []byte{1, 0, 0xbc, 2, 0, 0, currentDungeonResourceSelectedState}) {
|
||||
t.Fatalf("op5 dungeon resource state = header=%+v body=%x rest=%x", resourcePacket.Header, resourcePacket.Body, rest)
|
||||
}
|
||||
rest = assertCurrentPreDungeonContextPlayerState(t, session, rest)
|
||||
contextPacket, rest := splitGameServerUpperPacket(t, rest)
|
||||
if contextPacket.Header.MsgID != currentDungeonContextMsgID ||
|
||||
contextPacket.Header.Classification != 0 ||
|
||||
len(contextPacket.Body) != 37 {
|
||||
t.Fatalf("op27 handoff = header=%+v body=%x rest=%x", contextPacket.Header, contextPacket.Body, rest)
|
||||
}
|
||||
infoPacket, rest := splitGameServerUpperPacket(t, rest)
|
||||
if infoPacket.Header.MsgID != currentDungeonInfoNotification ||
|
||||
infoPacket.Header.Classification != 0 ||
|
||||
len(infoPacket.Body) != 36 {
|
||||
t.Fatalf("op28 dungeon info = header=%+v body=%x rest=%x", infoPacket.Header, infoPacket.Body, rest)
|
||||
}
|
||||
startPacket, rest := splitGameServerUpperPacket(t, rest)
|
||||
if startPacket.Header.MsgID != currentDungeonStartNotification ||
|
||||
startPacket.Header.Classification != 0 ||
|
||||
len(startPacket.Body) < 23 {
|
||||
t.Fatalf("op29 start map = header=%+v body=%x rest=%x", startPacket.Header, startPacket.Body, rest)
|
||||
}
|
||||
rest = assertCurrentPostStartMapPlayerState(t, session, rest, true, true)
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("trailing dungeon-entry packets=%x", rest)
|
||||
}
|
||||
if !session.selectPreviewActorRemoved {
|
||||
t.Fatal("validated first op16 did not remove the transition preview actor")
|
||||
}
|
||||
|
||||
session.dungeon.mu.Lock()
|
||||
runtime := session.dungeon.runtime
|
||||
session.dungeon.mu.Unlock()
|
||||
if runtime == nil || runtime.Session == nil || runtime.Dungeon.ID != 700 || runtime.MazeIndex != 0 {
|
||||
t.Fatalf("runtime = %+v", runtime)
|
||||
}
|
||||
scene, ok := runtime.Session.Scene()
|
||||
if !ok || scene.Map.Map.ID != 100 || scene.Map.Map.Path != "map/dungeon/test/start.map" {
|
||||
t.Fatalf("scene map = %+v, ok=%v", scene.Map, ok)
|
||||
}
|
||||
if len(scene.Monsters) != 1 || scene.Monsters[0].MonsterID != 3001 || len(scene.ExpectedHostiles) != 1 {
|
||||
t.Fatalf("scene hostiles = monsters=%+v expected=%+v", scene.Monsters, scene.ExpectedHostiles)
|
||||
}
|
||||
room := runtime.Room.Snapshot()
|
||||
if room.MapID != 100 || len(room.Monsters) != 1 || room.Monsters[0].ObjectKey != firstDungeonMonsterObjectKey {
|
||||
t.Fatalf("runtime monster room = %+v", room)
|
||||
}
|
||||
monster := room.Monsters[0]
|
||||
if monster.State != runtimeDungeonMonsterAnnounced || monster.Definition.ID != 3001 || monster.Definition.Name != "Synthetic Goblin" {
|
||||
t.Fatalf("runtime monster = %+v", monster)
|
||||
}
|
||||
announcedScene, _ := runtime.Session.Scene()
|
||||
if got, announced := announcedScene.RuntimeObjects[monster.ObjectKey]; !announced || got != monster.Reference {
|
||||
t.Fatalf("runtime object after start-map notification: key=%d binding=%+v announced=%v", monster.ObjectKey, got, announced)
|
||||
}
|
||||
conn.write.Reset()
|
||||
if err := service.handleGameUpper(session, frame); err != nil {
|
||||
t.Fatalf("handle duplicate select dungeon: %v", err)
|
||||
}
|
||||
duplicateAck, duplicateRest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if duplicateAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
|
||||
duplicateAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
!bytes.Equal(duplicateAck.Body, upperSuccessBody(buildSelectDungeonBodyForCharacter(99))) {
|
||||
t.Fatalf("duplicate select ACK = header=%+v body=%x", duplicateAck.Header, duplicateAck.Body)
|
||||
}
|
||||
if len(duplicateRest) != 0 {
|
||||
t.Fatalf("duplicate select replayed post-ACK packets: %x", duplicateRest)
|
||||
}
|
||||
if session.dungeon.runtime != runtime {
|
||||
t.Fatal("duplicate select replaced the active dungeon runtime")
|
||||
}
|
||||
|
||||
mismatchBody := append([]byte(nil), body...)
|
||||
mismatchBody[4]++
|
||||
mismatchFrame, err := dnfproto.BuildChannelPacket(
|
||||
uint16(dnfenum.UpperMsgSelectEnter), mismatchBody, 0, dnfproto.DefaultChannelClassification,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build mismatched duplicate select packet: %v", err)
|
||||
}
|
||||
conn.write.Reset()
|
||||
if err := service.handleGameUpper(session, mismatchFrame); err != nil {
|
||||
t.Fatalf("handle mismatched duplicate select: %v", err)
|
||||
}
|
||||
if conn.write.Len() != 0 {
|
||||
t.Fatalf("mismatched duplicate select emitted response: %x", conn.write.Bytes())
|
||||
}
|
||||
deathBody := make([]byte, 62)
|
||||
binary.LittleEndian.PutUint32(deathBody[0:4], monster.ObjectKey)
|
||||
// A hostile room actor (the victim itself) can never be the killer.
|
||||
binary.LittleEndian.PutUint16(deathBody[4:6], uint16(monster.ObjectKey))
|
||||
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
|
||||
t.Fatalf("handle mismatched monster owner: %v", err)
|
||||
}
|
||||
if got := runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterAnnounced {
|
||||
t.Fatalf("mismatched owner advanced monster state to %s", got)
|
||||
}
|
||||
sentinelBody := append([]byte(nil), deathBody...)
|
||||
binary.LittleEndian.PutUint16(sentinelBody[4:6], ^uint16(0))
|
||||
writesBeforeSentinel := conn.write.Len()
|
||||
if err := service.handleDungeonMonsterDeath(session, sentinelBody); err != nil {
|
||||
t.Fatalf("handle non-hostile-owner sentinel on monster: %v", err)
|
||||
}
|
||||
if got := runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterAnnounced {
|
||||
t.Fatalf("owner=ffff advanced hostile monster state to %s", got)
|
||||
}
|
||||
if conn.write.Len() != writesBeforeSentinel {
|
||||
t.Fatalf("owner=ffff hostile monster emitted response bytes: before=%d after=%d", writesBeforeSentinel, conn.write.Len())
|
||||
}
|
||||
playerObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
||||
binary.LittleEndian.PutUint16(deathBody[4:6], playerObjectKey)
|
||||
writesBeforeTail := conn.write.Len()
|
||||
if err := service.handleDungeonMonsterDeath(session, append(append([]byte(nil), deathBody...), 0xaa)); err != nil {
|
||||
t.Fatalf("handle tailed monster report: %v", err)
|
||||
}
|
||||
if got := runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterAnnounced {
|
||||
t.Fatalf("unproven op39 tail advanced monster state to %s", got)
|
||||
}
|
||||
if conn.write.Len() != writesBeforeTail {
|
||||
t.Fatalf("unproven op39 tail emitted response bytes: before=%d after=%d", writesBeforeTail, conn.write.Len())
|
||||
}
|
||||
writesBeforeDeath := conn.write.Len()
|
||||
deathFrame, err := dnfproto.BuildChannelPacket(
|
||||
uint16(dnfenum.CmdPacketDieMonster), deathBody, 1, dnfproto.DefaultChannelClassification,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("build current EXE monster death upper packet: %v", err)
|
||||
}
|
||||
if err := service.handleGameUpper(session, deathFrame); err != nil {
|
||||
t.Fatalf("dispatch current EXE monster death: %v", err)
|
||||
}
|
||||
reportedScene, _ := runtime.Session.Scene()
|
||||
reportedRoom := runtime.Room.Snapshot()
|
||||
if !reportedScene.Cleared || reportedRoom.Monsters[0].State != runtimeDungeonMonsterDefeated {
|
||||
t.Fatalf("validated client death report did not clear runtime = scene=%+v room=%+v", reportedScene, reportedRoom)
|
||||
}
|
||||
deathPacket, deathRest := splitGameServerUpperPacket(t, conn.write.Bytes()[writesBeforeDeath:])
|
||||
wantDeathBody, err := buildCurrentDungeonDeathNotificationBody(
|
||||
monster.ObjectKey,
|
||||
currentDungeonDeathResponseMonster,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deathPacket.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
|
||||
deathPacket.Header.Classification != 0 ||
|
||||
!bytes.Equal(deathPacket.Body, wantDeathBody) {
|
||||
t.Fatalf("monster death notification = header=%+v body=%x rest=%x", deathPacket.Header, deathPacket.Body, deathRest)
|
||||
}
|
||||
assertCurrentDungeonFinalClearTail(t, deathRest)
|
||||
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
|
||||
if err != nil || !found || inventory.Slots["0:1"].ItemID != 1 || inventory.Slots["0:1"].Count != 7 {
|
||||
t.Fatalf("client death report changed inventory: found=%t inventory=%+v err=%v", found, inventory, err)
|
||||
}
|
||||
|
||||
writesBeforeDuplicate := conn.write.Len()
|
||||
if err := service.handleGameUpper(session, deathFrame); err != nil {
|
||||
t.Fatalf("dispatch duplicate current EXE monster death: %v", err)
|
||||
}
|
||||
if conn.write.Len() != writesBeforeDuplicate {
|
||||
t.Fatalf("duplicate death emitted response: before=%d after=%d", writesBeforeDuplicate, conn.write.Len())
|
||||
}
|
||||
if _, _, err := runtime.Room.CommitActorDeathReport(monster.ObjectKey, runtime.Session); !errors.Is(err, errDungeonMonsterAlreadyDefeated) {
|
||||
t.Fatalf("duplicate death error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLegacySelectDungeonSharesCurrentOp16Owner(t *testing.T) {
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Level: 20,
|
||||
Stats: map[string]int64{
|
||||
"fatigue": 100, "town_id": 38, "area_id": 1,
|
||||
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonChoice: func(int) (int, error) { return 0, nil },
|
||||
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{
|
||||
conn: conn,
|
||||
connID: "legacy-op16-test",
|
||||
selectedCharacterID: 99,
|
||||
selectPreviewObjectStateSent: true,
|
||||
currentSceneObjectListSent: true,
|
||||
}
|
||||
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
|
||||
|
||||
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 700)
|
||||
body[4] = 1
|
||||
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeSelectDungeon), body); err != nil {
|
||||
t.Fatalf("legacy select dungeon: %v", err)
|
||||
}
|
||||
|
||||
previewRemoval, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
||||
if previewRemoval.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) {
|
||||
t.Fatalf("preview removal header=%+v", previewRemoval.Header)
|
||||
}
|
||||
selectAck, _ := splitGameServerUpperPacket(t, rest)
|
||||
if selectAck.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
|
||||
selectAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
!bytes.Equal(selectAck.Body, upperSuccessBody(buildSelectDungeonBodyForCharacter(99))) {
|
||||
t.Fatalf("legacy op16 did not reach current owner: header=%+v body=%x", selectAck.Header, selectAck.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonSelectUpperRejectsMalformedRequestWithoutStateOrReply(t *testing.T) {
|
||||
for _, bodyLen := range []int{4, dungeoncmd.SelectDungeonRequestSize + 1} {
|
||||
service := &Service{}
|
||||
conn := &bufferConn{}
|
||||
session := &gameSession{conn: conn, selectedCharacterID: 99}
|
||||
|
||||
service.handleDungeonSelectUpper(session, make([]byte, bodyLen))
|
||||
if conn.write.Len() != 0 {
|
||||
t.Fatalf("%d-byte malformed request produced reply: %x", bodyLen, conn.write.Bytes())
|
||||
}
|
||||
if session.dungeon.runtime != nil {
|
||||
t.Fatalf("%d-byte malformed request committed runtime: %+v", bodyLen, session.dungeon.runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonSelectUpperReportsEntryLevelTooLowWithoutRuntime(t *testing.T) {
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Level: 9,
|
||||
Stats: map[string]int64{
|
||||
"fatigue": 100, "town_id": 38, "area_id": 1,
|
||||
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
connection := &bufferConn{}
|
||||
session := &gameSession{conn: connection, selectedCharacterID: 99}
|
||||
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 700)
|
||||
binary.LittleEndian.PutUint16(body[9:11], 0xffff)
|
||||
|
||||
if err := service.handleDungeonSelectUpper(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
packet.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
|
||||
!bytes.Equal(packet.Body, []byte{0, 14, 0}) {
|
||||
t.Fatalf("level failure packet=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
t.Fatalf("level failure emitted trailing entry packets: %x", rest)
|
||||
}
|
||||
session.dungeon.mu.Lock()
|
||||
runtime := session.dungeon.runtime
|
||||
session.dungeon.mu.Unlock()
|
||||
if runtime != nil {
|
||||
t.Fatalf("level failure committed runtime: %+v", runtime)
|
||||
}
|
||||
if session.selectPreviewActorRemoved || session.preDungeonContextPlayerStateSent ||
|
||||
session.enterSelectDungeonContextSent || session.postStartMapPlayerStateSent ||
|
||||
session.sceneBootstrapTailSent {
|
||||
t.Fatalf("level failure advanced entry state: %+v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDungeonFailureReasonReturnsToSelectorForHellPartyRoomFailure(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
fmt.Errorf("prepare hell party runtime: %w", dnfhellparty.ErrRoomUnavailable),
|
||||
fmt.Errorf("prepare hell party runtime: %w", dnfhellparty.ErrRoomInvalid),
|
||||
} {
|
||||
reason, reply := selectDungeonFailureReason(err)
|
||||
if !reply || reason != currentSelectDungeonPreparationRejectedReason {
|
||||
t.Fatalf("selectDungeonFailureReason(%v) = %d, %t", err, reason, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeCurrentInfernoDungeonUsesCompletedPVFQuest(t *testing.T) {
|
||||
dungeon := worldmap.Dungeon{ID: 7527, Metadata: worldmap.DungeonMetadata{
|
||||
InfernoDungeon: true, InfernoQuestIDs: []int64{10104},
|
||||
}}
|
||||
if err := authorizeCurrentInfernoDungeon(dungeon, dnfrepo.QuestRecord{}); !errors.Is(err, errDungeonInfernoLocked) {
|
||||
t.Fatalf("missing Time Rift quest error=%v", err)
|
||||
}
|
||||
if err := authorizeCurrentInfernoDungeon(dungeon, dnfrepo.QuestRecord{
|
||||
States: map[int64]dnfrepo.QuestState{10104: {Status: "active", ProgressValue: 0}},
|
||||
}); !errors.Is(err, errDungeonInfernoLocked) {
|
||||
t.Fatalf("unfinished Time Rift quest error=%v", err)
|
||||
}
|
||||
if err := authorizeCurrentInfernoDungeon(dungeon, dnfrepo.QuestRecord{
|
||||
Progress: map[int64]dnfrepo.QuestState{10104: {Status: "completed", ProgressValue: 0}},
|
||||
}); err != nil {
|
||||
t.Fatalf("completed Time Rift quest error=%v", err)
|
||||
}
|
||||
if err := authorizeCurrentInfernoDungeon(worldmap.Dungeon{}, dnfrepo.QuestRecord{}); err != nil {
|
||||
t.Fatalf("ordinary dungeon error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDungeonFailureReasonReturnsToSelectorForUnknownPVFPreparationFailure(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
fmt.Errorf("build dungeon layout: %w", worldmap.ErrMapPoolEmpty),
|
||||
fmt.Errorf("resolve dungeon start map: %w", worldmap.ErrMapReferenceMissing),
|
||||
errors.New("future dungeon preparation failure"),
|
||||
} {
|
||||
reason, reply := selectDungeonFailureReason(err)
|
||||
if !reply || reason != currentSelectDungeonPreparationRejectedReason {
|
||||
t.Fatalf("selectDungeonFailureReason(%v) = %d, %t", err, reason, reply)
|
||||
}
|
||||
}
|
||||
if reason, reply := selectDungeonFailureReason(nil); reply || reason != 0 {
|
||||
t.Fatalf("nil failure = %d, %t", reason, reply)
|
||||
}
|
||||
if reason, reply := selectDungeonFailureReason(errDungeonAlreadyActive); reply || reason != 0 {
|
||||
t.Fatalf("active-runtime stale request = %d, %t", reason, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDungeonSelectUpperReturnsRetryableFailureForPreparationError(t *testing.T) {
|
||||
connection := &bufferConn{}
|
||||
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
|
||||
session := &gameSession{conn: connection, selectedCharacterID: 99}
|
||||
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 700)
|
||||
binary.LittleEndian.PutUint16(body[9:11], 0xffff)
|
||||
|
||||
if err := service.handleDungeonSelectUpper(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
||||
packet.Header.MsgID != uint16(dnfenum.CmdPacketSelectDungeon) ||
|
||||
!bytes.Equal(packet.Body, buildSelectDungeonFailureBody(currentSelectDungeonPreparationRejectedReason)) || len(rest) != 0 {
|
||||
t.Fatalf("preparation failure packet=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
||||
}
|
||||
if session.dungeon.runtime != nil {
|
||||
t.Fatalf("preparation failure committed runtime=%+v", session.dungeon.runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDungeonRuntimeValidatesPVFEntryRules(t *testing.T) {
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
tests := []struct {
|
||||
name string
|
||||
character dnfrepo.CharacterRecord
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "minimum level",
|
||||
character: dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "account-1", Level: 9, Stats: map[string]int64{"fatigue": 100}},
|
||||
wantErr: errDungeonLevelTooLow,
|
||||
},
|
||||
{
|
||||
name: "fatigue unavailable",
|
||||
character: dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "account-1", Level: 20},
|
||||
wantErr: errDungeonFatigueUnknown,
|
||||
},
|
||||
{
|
||||
name: "fatigue exhausted",
|
||||
character: dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "account-1", Level: 20, Stats: map[string]int64{"fatigue": 0}},
|
||||
wantErr: errDungeonFatigueExhausted,
|
||||
},
|
||||
{
|
||||
name: "account mismatch",
|
||||
character: dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "account-2", Level: 20, Stats: map[string]int64{"fatigue": 100}},
|
||||
wantErr: errDungeonAccountMismatch,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), test.character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1"},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonChoice: func(int) (int, error) { return 0, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) {
|
||||
return repositories, true
|
||||
},
|
||||
}
|
||||
_, _, err := service.prepareDungeonRuntime(
|
||||
context.Background(),
|
||||
&gameSession{selectedCharacterID: 99},
|
||||
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
|
||||
)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("prepare error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDungeonRuntimeHonorsPVFNoFatigue(t *testing.T) {
|
||||
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(true))
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "account-1",
|
||||
Level: 20,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{
|
||||
options: options{accountID: "account-1"},
|
||||
worldMapTable: table,
|
||||
worldMapResolver: resolver,
|
||||
dungeonMonsterTable: monsters,
|
||||
dungeonChoice: func(int) (int, error) { return 0, nil },
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) {
|
||||
return repositories, true
|
||||
},
|
||||
}
|
||||
runtime, scene, err := service.prepareDungeonRuntime(
|
||||
context.Background(),
|
||||
&gameSession{selectedCharacterID: 99},
|
||||
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare no-fatigue dungeon: %v", err)
|
||||
}
|
||||
if runtime == nil || scene.Map.Map.ID != 100 {
|
||||
t.Fatalf("runtime=%+v scene=%+v", runtime, scene)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseDungeonIndexUsesServerRandomnessForPVFAmbiguity(t *testing.T) {
|
||||
service := &Service{}
|
||||
if index, err := service.chooseDungeonIndex(1); err != nil || index != 0 {
|
||||
t.Fatalf("single PVF candidate = index=%d err=%v", index, err)
|
||||
}
|
||||
if index, err := service.chooseDungeonIndex(2); err != nil || index < 0 || index >= 2 {
|
||||
t.Fatalf("server-random PVF candidate = index=%d err=%v", index, err)
|
||||
}
|
||||
service.dungeonChoice = func(limit int) (int, error) {
|
||||
if limit != 2 {
|
||||
t.Fatalf("choice limit = %d", limit)
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if index, err := service.chooseDungeonIndex(2); err != nil || index != 1 {
|
||||
t.Fatalf("explicit PVF candidate = index=%d err=%v", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeDungeonRoomRejectsMissingDefinitionAndObjectKeyOverflow(t *testing.T) {
|
||||
_, _, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
missing := worldmap.DungeonRoomScene{
|
||||
Coordinate: worldmap.RoomCoordinate{X: 2, Y: 3},
|
||||
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 101}},
|
||||
Monsters: []worldmap.MonsterSpawn{{MonsterID: 9999}},
|
||||
}
|
||||
if _, _, err := newRuntimeDungeonRoom(missing, monsters, firstDungeonMonsterObjectKey); !errors.Is(err, errDungeonMonsterDefinitionMiss) {
|
||||
t.Fatalf("missing monster definition error = %v", err)
|
||||
}
|
||||
overflow := missing
|
||||
overflow.Monsters[0].MonsterID = 3001
|
||||
if _, _, err := newRuntimeDungeonRoom(overflow, monsters, uint32(^uint16(0))+1); !errors.Is(err, errDungeonMonsterObjectKeyRange) {
|
||||
t.Fatalf("monster object key overflow error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeDungeonRoomPreservesUnsupportedHostiles(t *testing.T) {
|
||||
_, _, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
reference := worldmap.HostileReference{Kind: worldmap.HostileAICharacter, Index: 4}
|
||||
room, next, err := newRuntimeDungeonRoom(worldmap.DungeonRoomScene{
|
||||
Coordinate: worldmap.RoomCoordinate{X: 1, Y: 1},
|
||||
Map: worldmap.ResolvedMap{Map: worldmap.Map{ID: 102}},
|
||||
ExpectedHostiles: []worldmap.HostileReference{reference},
|
||||
}, monsters, firstDungeonMonsterObjectKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := room.Snapshot()
|
||||
if next != firstDungeonMonsterObjectKey || len(snapshot.Monsters) != 0 || len(snapshot.OpaqueHostiles) != 1 || snapshot.OpaqueHostiles[0] != reference {
|
||||
t.Fatalf("opaque hostile preservation = snapshot=%+v next=%d", snapshot, next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeDungeonRoomMarksExplicitFriendlyMonsterTeamNonBlocking(t *testing.T) {
|
||||
_, _, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
||||
spawns := []worldmap.MonsterSpawn{
|
||||
{MonsterID: 3001, Rank: "[normal]"},
|
||||
{MonsterID: 3001, Rank: "[normal]"},
|
||||
{MonsterID: 3001, Rank: "[normal]"},
|
||||
}
|
||||
room, _, err := newRuntimeDungeonRoom(worldmap.DungeonRoomScene{
|
||||
Coordinate: worldmap.RoomCoordinate{X: 3, Y: 0},
|
||||
Map: worldmap.ResolvedMap{Map: worldmap.Map{
|
||||
ID: 76329,
|
||||
Monsters: spawns,
|
||||
MonsterTeam: []int64{100, 0},
|
||||
}},
|
||||
Monsters: spawns,
|
||||
}, monsters, firstDungeonMonsterObjectKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := room.Snapshot()
|
||||
if len(snapshot.Monsters) != 3 {
|
||||
t.Fatalf("monsters=%+v", snapshot.Monsters)
|
||||
}
|
||||
if snapshot.Monsters[0].RoomClearSkipByte != 0 ||
|
||||
snapshot.Monsters[1].RoomClearSkipByte != 1 ||
|
||||
snapshot.Monsters[2].RoomClearSkipByte != 0 {
|
||||
t.Fatalf("room-clear skip bytes=%d,%d,%d want=0,1,0",
|
||||
snapshot.Monsters[0].RoomClearSkipByte,
|
||||
snapshot.Monsters[1].RoomClearSkipByte,
|
||||
snapshot.Monsters[2].RoomClearSkipByte,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type bridgePVFSource map[string]string
|
||||
|
||||
func (s bridgePVFSource) ReadText(relativePath string) (string, error) {
|
||||
for path, text := range s {
|
||||
if worldmapPathKey(path) == worldmapPathKey(relativePath) {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
return "", dnfpvf.ErrDocNotFound
|
||||
}
|
||||
|
||||
func worldmapPathKey(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func loadBridgeDungeonStaticData(t *testing.T, source bridgePVFSource) (*worldmap.Table, *worldmap.Resolver, *pvfDungeonMonsterCatalog) {
|
||||
t.Helper()
|
||||
table, err := worldmap.LoadSource(context.Background(), source, worldmap.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("load synthetic worldmap PVF: %v", err)
|
||||
}
|
||||
resolver, err := worldmap.NewResolver(table)
|
||||
if err != nil {
|
||||
t.Fatalf("build synthetic worldmap resolver: %v", err)
|
||||
}
|
||||
monsters, err := newPVFDungeonMonsterCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatalf("load synthetic monster catalog: %v", err)
|
||||
}
|
||||
return table, resolver, monsters
|
||||
}
|
||||
|
||||
func bridgeDungeonPVF(noFatigue bool) bridgePVFSource {
|
||||
noFatigueSection := ""
|
||||
if noFatigue {
|
||||
noFatigueSection = "[no fatigue]\n"
|
||||
}
|
||||
return bridgePVFSource{
|
||||
worldmap.DefaultMapList: "100 `dungeon/test/start.map`\n",
|
||||
"map/dungeon/test/start.map": "[map name]\n`start`\n" +
|
||||
"[dungeon]\n700\n" +
|
||||
"[type]\n`[start]`\n" +
|
||||
"[monster]\n3001 10 0 100 200 0 0 0 `[fixed]` `[normal]`\n",
|
||||
worldmap.DefaultDungeonList: "700 `test.dgn`\n",
|
||||
"dungeon/test.dgn": "[name]\n`Synthetic Dungeon`\n" +
|
||||
"[minimum required level]\n10\n" +
|
||||
"[basis level]\n20\n" +
|
||||
noFatigueSection +
|
||||
"[limit party count]\n1\n" +
|
||||
"[maze info]\n" +
|
||||
"[size]\n1 1\n" +
|
||||
"[greed]\n`A`\n" +
|
||||
"[map specification]\n`map` 0 0 100\n" +
|
||||
"[start map]\n0 0\n" +
|
||||
"[boss map]\n0 0\n",
|
||||
worldmap.DefaultWorldMapList: "1 `test.wdm`\n",
|
||||
"worldmap/test.wdm": "[name]\n`Synthetic Area`\n[dungeon]\n700 -1\n[/dungeon]\n",
|
||||
dnfmonster.DefaultList: "3001 `test.gob`\n",
|
||||
"monster/test.gob": "[name]\n`Synthetic Goblin`\n" +
|
||||
"[level]\n10\n" +
|
||||
"[hp]\n500\n" +
|
||||
"[exp]\n25\n",
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user