服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
288 lines
9.6 KiB
Go
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
|
|
}
|