Files
90CNServer/server/internal/modules/dnf/worldmap/dungeon_layout_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

288 lines
9.6 KiB
Go

package worldmap
import (
"errors"
"testing"
)
func TestBuildDungeonLayoutAssignsExplicitCandidatesAndTypedPools(t *testing.T) {
table := layoutTestTable()
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
var choices []DungeonMapChoice
topology, err := BuildDungeonLayout(resolver, 800, 0, func(choice DungeonMapChoice) (int64, error) {
choices = append(choices, choice)
switch choice.Coordinate {
case (RoomCoordinate{X: 0, Y: 0}):
return 501, nil
case (RoomCoordinate{X: 1, Y: 0}):
return 502, nil
default:
return 0, errors.New("unexpected room")
}
})
if err != nil {
t.Fatal(err)
}
if len(choices) != 2 {
t.Fatalf("choices = %+v", choices)
}
var coordinateChoice, poolChoice *DungeonMapChoice
for index := range choices {
switch choices[index].Coordinate {
case (RoomCoordinate{X: 0, Y: 0}):
coordinateChoice = &choices[index]
case (RoomCoordinate{X: 1, Y: 0}):
poolChoice = &choices[index]
}
}
if coordinateChoice == nil || coordinateChoice.Source != ResolutionDungeonOwnership || len(coordinateChoice.Candidates) != 2 {
t.Fatalf("coordinate choice = %+v", coordinateChoice)
}
if poolChoice == nil || poolChoice.Source != ResolutionTypePool || len(poolChoice.Candidates) != 1 {
t.Fatalf("pool choice = %+v", poolChoice)
}
start, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || start.Map == nil || start.Map.Map.ID != 501 {
t.Fatalf("start room = %+v ok=%v", start, ok)
}
boss, ok := topology.Room(RoomCoordinate{X: 1, Y: 0})
if !ok || boss.Map == nil || boss.Map.Map.ID != 502 {
t.Fatalf("boss room = %+v ok=%v", boss, ok)
}
if len(topology.UnresolvedRooms()) != 0 || len(topology.resolutionErrors) != 0 {
t.Fatalf("layout unresolved = %+v errors=%v", topology.UnresolvedRooms(), topology.resolutionErrors)
}
}
func TestBuildDungeonLayoutRequiresAndValidatesChoice(t *testing.T) {
resolver, err := NewResolver(layoutTestTable())
if err != nil {
t.Fatal(err)
}
if _, err := BuildDungeonLayout(resolver, 800, 0, nil); !errors.Is(err, ErrMapChoiceRequired) {
t.Fatalf("missing chooser = %v", err)
}
_, err = BuildDungeonLayout(resolver, 800, 0, func(DungeonMapChoice) (int64, error) {
return 9999, nil
})
if !errors.Is(err, ErrMapChoiceRejected) {
t.Fatalf("rejected choice = %v", err)
}
}
func TestBuildDungeonLayoutSingleStartBossRoomFallsBackToNormalOwnedPool(t *testing.T) {
table := &Table{
maps: []Map{
{
ID: 15130,
Path: "map/towerofdespair_down/15130despair001.map",
Type: "[normal]",
DungeonIDs: []int64{11008},
},
},
dungeons: []Dungeon{{
ID: 11008,
Path: "dungeon/Towers/ZTowerOfDespair001.dgn",
Mazes: []Maze{{
Index: 0,
Width: OptionalInt{Value: 1, Set: true},
Height: OptionalInt{Value: 1, Set: true},
Greed: "BB",
Start: &MazePoint{X: 0, Y: 0},
Boss: &MazePoint{X: 0, Y: 0},
}},
}},
mapByID: map[int64]int{15130: 0},
mapByPath: map[string]int{"map/towerofdespair_down/15130despair001.map": 0},
dungeonByID: map[int64]int{11008: 0},
dungeonByPath: map[string]int{"dungeon/towers/ztowerofdespair001.dgn": 0},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
if _, err := resolver.PoolCandidates(MapPoolRequest{
DungeonID: 11008,
MazeIndex: 0,
FileType: MapFileBoss,
}); !errors.Is(err, ErrMapPoolEmpty) {
t.Fatalf("fixture boss pool should be empty, got %v", err)
}
topology, err := BuildDungeonLayout(resolver, 11008, 0, nil)
if err != nil {
t.Fatalf("single start/boss layout should use normal owned pool: %v", err)
}
room, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || !room.Start || !room.Boss || room.Map == nil || room.Map.Map.ID != 15130 ||
room.Map.Source != ResolutionTypePool {
t.Fatalf("single start/boss room = %+v ok=%v", room, ok)
}
if len(topology.UnresolvedRooms()) != 0 || len(topology.resolutionErrors) != 0 {
t.Fatalf("layout unresolved = %+v errors=%v", topology.UnresolvedRooms(), topology.resolutionErrors)
}
}
func TestBuildDungeonLayoutSingleStartBossRoomUsesCoordinateOwnedStartMap(t *testing.T) {
table := &Table{
maps: []Map{{
ID: 36250,
Path: "map/PoongjinTrainingRoom/11100(0,0)start.map",
DungeonIDs: []int64{5000},
}},
dungeons: []Dungeon{{
ID: 5000,
Path: "dungeon/PoongjinTrainingRoom/PoongjinTrainingRoom.dgn",
Mazes: []Maze{{
Index: 0,
Width: OptionalInt{Value: 1, Set: true},
Height: OptionalInt{Value: 1, Set: true},
Greed: "BB",
Start: &MazePoint{X: 0, Y: 0},
Boss: &MazePoint{X: 0, Y: 0},
}},
}},
mapByID: map[int64]int{36250: 0},
mapByPath: map[string]int{"map/poongjintrainingroom/11100(0,0)start.map": 0},
dungeonByID: map[int64]int{5000: 0},
dungeonByPath: map[string]int{"dungeon/poongjintrainingroom/poongjintrainingroom.dgn": 0},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonLayout(resolver, 5000, 0, nil)
if err != nil {
t.Fatalf("training-room layout should use its coordinate-owned start map: %v", err)
}
room, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || !room.Start || !room.Boss || room.Map == nil ||
room.Map.Map.ID != 36250 || room.Map.Source != ResolutionDungeonOwnership {
t.Fatalf("training-room start/boss room = %+v ok=%v", room, ok)
}
if len(topology.UnresolvedRooms()) != 0 || len(topology.resolutionErrors) != 0 {
t.Fatalf("layout unresolved = %+v errors=%v", topology.UnresolvedRooms(), topology.resolutionErrors)
}
}
func TestBuildDungeonLayoutKeepsUnassignedSpecialRoomsLazyButRequiresStartMap(t *testing.T) {
newTable := func(includeStart bool) *Table {
maps := []Map(nil)
mapByID := map[int64]int{}
mapByPath := map[string]int{}
if includeStart {
maps = append(maps, Map{
ID: 11200, Path: "map/forgotten_land/forgotten_land_1.map",
DungeonIDs: []int64{4000},
})
mapByID[11200] = 0
mapByPath["map/forgotten_land/forgotten_land_1.map"] = 0
}
return &Table{
maps: maps,
dungeons: []Dungeon{{
ID: 4000, Path: "dungeon/fun_test/fun_test2.dgn",
Mazes: []Maze{{
Index: 0, Width: OptionalInt{Value: 10, Set: true}, Height: OptionalInt{Value: 10, Set: true},
Greed: "BBBB", Start: &MazePoint{X: 9, Y: 6}, Boss: &MazePoint{X: 9, Y: 0},
MapSpecifications: []MapSpecification{{
Type: "map", Coordinate: Point{X: 9, Y: 6}, MapIDs: []int64{11200},
}},
}},
}},
mapByID: mapByID, mapByPath: mapByPath,
dungeonByID: map[int64]int{4000: 0},
dungeonByPath: map[string]int{"dungeon/fun_test/fun_test2.dgn": 0},
}
}
resolver, err := NewResolver(newTable(true))
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonLayout(resolver, 4000, 0, nil)
if err != nil {
t.Fatalf("logical unassigned boss must not reject entry: %v", err)
}
start, ok := topology.Room(RoomCoordinate{X: 9, Y: 6})
if !ok || start.Map == nil || start.Map.Map.ID != 11200 {
t.Fatalf("start=%+v ok=%t", start, ok)
}
boss, ok := topology.Room(RoomCoordinate{X: 9, Y: 0})
if !ok || boss.Map != nil || boss.ResolutionError == "" {
t.Fatalf("logical boss=%+v ok=%t", boss, ok)
}
if _, err := NewDungeonRun(topology); err != nil {
t.Fatalf("entry run should require only the resolved start room: %v", err)
}
if _, err = NewResolver(newTable(false)); !errors.Is(err, ErrMapReferenceMissing) {
t.Fatalf("missing explicit start reference should remain fatal, got %v", err)
}
}
func TestBuildDungeonLayoutQuestStartUsesExactOwnedNormalCoordinate(t *testing.T) {
table := &Table{
maps: []Map{
{ID: 1639, Path: "map/draconiantower/b_1639(0,1)n.map", DungeonIDs: []int64{11}},
{ID: 1619, Path: "map/draconiantower/(0,0)b.map", DungeonIDs: []int64{11}},
},
dungeons: []Dungeon{{
ID: 11, Path: "dungeon/act2/draconiantower.dgn",
Mazes: []Maze{{
Index: 0, Width: OptionalInt{Value: 1, Set: true}, Height: OptionalInt{Value: 2, Set: true},
Greed: "AABB", Start: &MazePoint{X: 0, Y: 1}, Boss: &MazePoint{X: 0, Y: 0},
QuestConnection: []int64{0, 4307, 2},
MapSpecifications: []MapSpecification{{Type: "boss", Coordinate: Point{X: 0, Y: 0}, MapIDs: []int64{1619}}},
}},
}},
mapByID: map[int64]int{1639: 0, 1619: 1},
mapByPath: map[string]int{"map/draconiantower/b_1639(0,1)n.map": 0, "map/draconiantower/(0,0)b.map": 1},
dungeonByID: map[int64]int{11: 0},
dungeonByPath: map[string]int{"dungeon/act2/draconiantower.dgn": 0},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonLayout(resolver, 11, 0, nil)
if err != nil {
t.Fatal(err)
}
start, ok := topology.Room(RoomCoordinate{X: 0, Y: 1})
if !ok || start.Map == nil || start.Map.Map.ID != 1639 || start.Map.Source != ResolutionDungeonOwnership {
t.Fatalf("quest start=%+v ok=%t", start, ok)
}
}
func layoutTestTable() *Table {
maps := []Map{
{ID: 500, Path: "map/layout/500(0,0)start.map", DungeonIDs: []int64{800}},
{ID: 501, Path: "map/layout/501(0,0)start.map", DungeonIDs: []int64{800}},
{ID: 502, Path: "map/layout/b502.map", DungeonIDs: []int64{800}},
}
dungeons := []Dungeon{{
ID: 800, Path: "dungeon/layout.dgn",
Mazes: []Maze{{
Index: 0, Width: OptionalInt{Value: 2, Set: true}, Height: OptionalInt{Value: 1, Set: true},
Greed: "BBEE", Start: &MazePoint{X: 0, Y: 0}, Boss: &MazePoint{X: 1, Y: 0},
}},
}}
table := &Table{
maps: maps, dungeons: dungeons,
mapByID: map[int64]int{500: 0, 501: 1, 502: 2},
mapByPath: map[string]int{
"map/layout/500(0,0)start.map": 0,
"map/layout/501(0,0)start.map": 1,
"map/layout/b502.map": 2,
},
dungeonByID: map[int64]int{800: 0},
dungeonByPath: map[string]int{"dungeon/layout.dgn": 0},
}
return table
}