Files
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

417 lines
13 KiB
Go

package worldmap
import (
"errors"
"reflect"
"testing"
)
func TestDungeonTopologyBuildsStrictPVFRoomGraph(t *testing.T) {
resolver, err := NewResolver(topologyTestTable())
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatalf("build topology: %v", err)
}
rooms := topology.Rooms()
if len(rooms) != 5 {
t.Fatalf("rooms=%d, want 5: %+v", len(rooms), rooms)
}
if topology.Start == nil || *topology.Start != (RoomCoordinate{X: 0, Y: 0}) {
t.Fatalf("start=%+v", topology.Start)
}
if len(topology.Bosses) != 1 || topology.Bosses[0] != (RoomCoordinate{X: 2, Y: 1}) {
t.Fatalf("bosses=%+v", topology.Bosses)
}
start, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || start.Map == nil || start.Map.Map.ID != 100 || !start.Start || start.Boss {
t.Fatalf("start room=%+v ok=%v", start, ok)
}
if len(start.Neighbors) != 2 || start.Neighbors[0].Direction != RoomDirectionRight || start.Neighbors[1].Direction != RoomDirectionDown {
t.Fatalf("start neighbors=%+v", start.Neighbors)
}
if _, ok := topology.Room(RoomCoordinate{X: 1, Y: 1}); ok {
t.Fatal("closed [greed] cell was added to topology")
}
if unresolved := topology.UnresolvedRooms(); len(unresolved) != 0 {
t.Fatalf("unresolved rooms=%+v", unresolved)
}
}
func TestDungeonTopologyPreservesPVFAuthoredMonsterRanksAcrossRoomReads(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 800,
MazeIndex: 0,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{
ID: 501,
Monsters: []MonsterSpawn{
{MonsterID: 1, Rank: "[normal]"},
{MonsterID: 2, Rank: "[dummy]"},
{MonsterID: 3, Rank: "[champion]"},
{MonsterID: 4, Rank: "[super champion]"},
{MonsterID: 5, Rank: "[boss]"},
},
}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
first, ok := topology.Room(start)
if !ok || first.Map == nil {
t.Fatalf("first room=%+v ok=%t", first, ok)
}
wantRanks := []string{"[normal]", "[dummy]", "[champion]", "[super champion]", "[boss]"}
if got := monsterSpawnRanks(first.Map.Map.Monsters); !reflect.DeepEqual(got, wantRanks) {
t.Fatalf("first ranks=%v, want authored ranks %v", got, wantRanks)
}
first.Map.Map.Monsters[0].Rank = "[champion]"
for read := 0; read < 3; read++ {
room, roomOK := topology.Room(start)
if !roomOK || room.Map == nil {
t.Fatalf("read %d room=%+v ok=%t", read, room, roomOK)
}
if got := monsterSpawnRanks(room.Map.Map.Monsters); !reflect.DeepEqual(got, wantRanks) {
t.Fatalf("read %d ranks=%v, want authored %v", read, got, wantRanks)
}
}
rooms := topology.Rooms()
if len(rooms) != 1 || rooms[0].Map == nil {
t.Fatalf("rooms=%+v", rooms)
}
if got := monsterSpawnRanks(rooms[0].Map.Map.Monsters); !reflect.DeepEqual(got, wantRanks) {
t.Fatalf("Rooms ranks=%v, want authored %v", got, wantRanks)
}
}
func monsterSpawnRanks(spawns []MonsterSpawn) []string {
ranks := make([]string, len(spawns))
for index, spawn := range spawns {
ranks[index] = spawn.Rank
}
return ranks
}
func TestDungeonRunValidatesCurrentEXECoordinatesWithoutFallback(t *testing.T) {
resolver, err := NewResolver(topologyTestTable())
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
if room, ok := run.CurrentRoom(); !ok || room.Coordinate.String() != "0:0" {
t.Fatalf("current room=%+v ok=%v", room, ok)
}
if _, err := run.MoveByte(1, 0); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("uncleared move error=%v", err)
}
if err := run.MarkCurrentRoomCleared(); err != nil {
t.Fatal(err)
}
room, err := run.MoveByte(1, 0)
if err != nil || room.Map == nil || room.Map.Map.ID != 101 {
t.Fatalf("adjacent move room=%+v err=%v", room, err)
}
if _, err := run.MoveTo(RoomCoordinate{X: 2, Y: 1}); !errors.Is(err, ErrRoomNotAdjacent) {
t.Fatalf("diagonal/far move error=%v", err)
}
if _, err := run.MoveTo(RoomCoordinate{X: 1, Y: 1}); !errors.Is(err, ErrRoomNotFound) {
t.Fatalf("closed cell move error=%v", err)
}
if err := run.MarkCurrentRoomCleared(); err != nil {
t.Fatal(err)
}
if _, err := run.MoveRoomID("2:0"); err != nil {
t.Fatalf("room id move: %v", err)
}
if err := run.Complete(); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("complete uncleared room error=%v", err)
}
if err := run.MarkCurrentRoomCleared(); err != nil {
t.Fatal(err)
}
if err := run.Complete(); err != nil {
t.Fatal(err)
}
snapshot := run.Snapshot()
if snapshot.Status != DungeonRunCompleted || snapshot.Current != (RoomCoordinate{X: 2, Y: 0}) || len(snapshot.Visited) != 3 || len(snapshot.Cleared) != 3 {
t.Fatalf("snapshot=%+v", snapshot)
}
if _, err := run.MoveByte(2, 1); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("move after completion error=%v", err)
}
}
func TestDungeonRunUsesPVFMoveBeforeClearFlag(t *testing.T) {
table := topologyTestTable()
table.dungeons[0].Metadata.Flags = map[string]bool{"move map even enemy": true}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if !topology.AllowMoveBeforeClear {
t.Fatal("PVF move-before-clear flag was not retained")
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
if _, err := run.MoveByte(1, 0); err != nil {
t.Fatalf("PVF-authorized move failed: %v", err)
}
}
func TestDungeonRunUsesDeclaredDirectedWarpTransitions(t *testing.T) {
table := topologyTestTable()
table.dungeons[0].Metadata.Flags = map[string]bool{"move map even enemy": true}
table.dungeons[0].WarpMapConditions = []WarpMapCondition{
{Source: Point{X: 0, Y: 0}, Destination: Point{X: 2, Y: 1}},
{Source: Point{X: -1, Y: -1}, Destination: Point{X: 2, Y: 0}},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if len(topology.warpTransitions) != 1 {
t.Fatalf("warp transitions=%d want=1", len(topology.warpTransitions))
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
room, err := run.MoveTo(RoomCoordinate{X: 2, Y: 1})
if err != nil || room.Map == nil || room.Map.Map.ID != 104 {
t.Fatalf("declared warp room=%+v err=%v", room, err)
}
if _, err := run.MoveTo(RoomCoordinate{X: 0, Y: 0}); !errors.Is(err, ErrRoomNotAdjacent) {
t.Fatalf("undeclared reverse warp error=%v", err)
}
}
func TestDungeonRunDoesNotTreatBossEntranceWarpAsTravelEdge(t *testing.T) {
table := topologyTestTable()
table.dungeons[0].Metadata.Flags = map[string]bool{"move map even enemy": true}
maze := &table.dungeons[0].Mazes[0]
maze.BossRoomEntranceCondition = &BossRoomEntranceCondition{
Action: "clear action",
HuntMonsters: []BossRoomEntranceHuntMonster{{TargetID: 69201, Count: 1}},
}
maze.WarpMapConditions = []WarpMapCondition{
{Source: Point{X: 0, Y: 0}, Destination: Point{X: 2, Y: 1}},
}
table.dungeons[0].WarpMapConditions = append([]WarpMapCondition(nil), maze.WarpMapConditions...)
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if len(topology.warpTransitions) != 0 {
t.Fatalf("boss-entrance warp became travel edges: %+v", topology.warpTransitions)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
if _, err := run.MoveTo(RoomCoordinate{X: 2, Y: 1}); !errors.Is(err, ErrRoomNotAdjacent) {
t.Fatalf("boss-entrance destination accepted as direct travel: %v", err)
}
}
func TestDungeonTopologyRetainsMalformedAndUnresolvedRooms(t *testing.T) {
table := topologyTestTable()
table.maps = table.maps[:1]
table.mapByID = map[int64]int{100: 0}
table.mapByPath = map[string]int{pathKey(table.maps[0].Path): 0}
maze := &table.dungeons[0].Mazes[0]
maze.Width = OptionalInt{Set: true, Value: 2}
maze.Height = OptionalInt{Set: true, Value: 1}
maze.Greed = "BBEE"
maze.Boss = nil
maze.MapSpecifications = maze.MapSpecifications[:1]
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
unresolved := topology.UnresolvedRooms()
if len(unresolved) != 1 || unresolved[0].Coordinate != (RoomCoordinate{X: 1, Y: 0}) || unresolved[0].ResolutionError == "" {
t.Fatalf("unresolved rooms=%+v", unresolved)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
if err := run.MarkCurrentRoomCleared(); err != nil {
t.Fatal(err)
}
_, err = run.MoveByte(1, 0)
if !errors.Is(err, ErrRoomMapUnresolved) || !errors.Is(err, ErrMapUnresolved) {
t.Fatalf("unresolved move error=%v", err)
}
maze.Greed = "1"
resolver, err = NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err = BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if !hasTopologyDiagnostic(topology.Diagnostics, "maze_grid_row_short") {
t.Fatalf("missing malformed greed diagnostic: %+v", topology.Diagnostics)
}
maze.Greed = "BBEF"
resolver, err = NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err = BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if !hasTopologyDiagnostic(topology.Diagnostics, "maze_grid_pair_mismatch") {
t.Fatalf("missing mismatched pair diagnostic: %+v", topology.Diagnostics)
}
maze.Greed = "BBZZ"
resolver, err = NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err = BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if !hasTopologyDiagnostic(topology.Diagnostics, "maze_grid_code_unknown") {
t.Fatalf("missing unknown code diagnostic: %+v", topology.Diagnostics)
}
}
func TestDungeonTopologyKeepsRepeatedBossCoordinates(t *testing.T) {
table := topologyTestTable()
table.dungeons[0].Mazes[0].Boss = &MazePoint{X: 2, Y: 1, Params: []int64{2, 0, 99}}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 800, 0)
if err != nil {
t.Fatal(err)
}
if len(topology.Bosses) != 2 || topology.Bosses[1] != (RoomCoordinate{X: 2, Y: 0}) {
t.Fatalf("boss coordinates=%+v", topology.Bosses)
}
room, ok := topology.Room(RoomCoordinate{X: 2, Y: 0})
if !ok || !room.Boss {
t.Fatalf("second boss room=%+v ok=%v", room, ok)
}
}
func TestRoomCoordinateParsing(t *testing.T) {
coordinate, err := ParseRoomCoordinate(" -2:15 ")
if err != nil || coordinate != (RoomCoordinate{X: -2, Y: 15}) || coordinate.String() != "-2:15" {
t.Fatalf("coordinate=%+v err=%v", coordinate, err)
}
for _, value := range []string{"", "1", "1:2:3", "a:2"} {
if _, err := ParseRoomCoordinate(value); !errors.Is(err, ErrRoomCoordinateMalformed) {
t.Errorf("ParseRoomCoordinate(%q) error=%v", value, err)
}
}
}
func TestGreedRoomMasksFollowLocalPVFEncoding(t *testing.T) {
tests := []struct {
code rune
mask uint8
}{
{'A', 0x00},
{'B', 0x01},
{'C', 0x02},
{'E', 0x04},
{'I', 0x08},
{'P', 0x0f},
{'p', 0x0f},
}
for _, test := range tests {
mask, ok := greedRoomMask(test.code)
if !ok || mask != test.mask {
t.Errorf("greedRoomMask(%q)=(%02x,%v), want %02x", test.code, mask, ok, test.mask)
}
}
if _, ok := greedRoomMask('Z'); ok {
t.Fatal("unknown local PVF room code was accepted")
}
}
func hasTopologyDiagnostic(diagnostics []TopologyDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
func topologyTestTable() *Table {
maps := []Map{
{ID: 100, Path: "map/topology/s100(0,0).map"},
{ID: 101, Path: "map/topology/101(1,0).map"},
{ID: 102, Path: "map/topology/102(2,0).map"},
{ID: 103, Path: "map/topology/103(0,1).map"},
{ID: 104, Path: "map/topology/104(2,1)boss.map"},
}
dungeon := Dungeon{
ID: 800, Path: "dungeon/topology.dgn",
Mazes: []Maze{{
Index: 0,
Width: OptionalInt{Set: true, Value: 3}, Height: OptionalInt{Set: true, Value: 2},
Greed: "JJFFMM\nCCAACC",
Start: &MazePoint{X: 0, Y: 0}, Boss: &MazePoint{X: 2, Y: 1},
MapSpecifications: []MapSpecification{
{Type: "start", Coordinate: Point{X: 0, Y: 0}, MapIDs: []int64{100}},
{Type: "boss", Coordinate: Point{X: 2, Y: 1}, MapIDs: []int64{104}},
},
}},
}
table := &Table{
maps: maps, dungeons: []Dungeon{dungeon},
mapByID: make(map[int64]int), mapByPath: make(map[string]int),
dungeonByID: map[int64]int{800: 0}, dungeonByPath: map[string]int{pathKey(dungeon.Path): 0},
}
for index, mapValue := range maps {
table.mapByID[mapValue.ID] = index
table.mapByPath[pathKey(mapValue.Path)] = index
}
return table
}