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

1163 lines
40 KiB
Go

package worldmap
import (
"errors"
"reflect"
"testing"
)
func TestDungeonSessionBindsEXEObjectKeysAndEnforcesRoomClear(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
topology := &DungeonTopology{
DungeonID: 700,
MazeIndex: 0,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Source: ResolutionMapSpecification, Map: Map{
ID: 100,
Monsters: []MonsterSpawn{{MonsterID: 3001}, {MonsterID: 3002}},
AICharacters: []AICharacter{{Code: 4001, Faction: "[monster]"}, {Code: 4002, Faction: "[neutral]"}},
NPCs: []NPCSpawn{{NPCID: 5001}},
}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: RoomCoordinate{X: 1, Y: 0}}},
},
{x: 1, y: 0}: {
Coordinate: RoomCoordinate{X: 1, Y: 0},
Boss: true,
Map: &ResolvedMap{Source: ResolutionMapSpecification, Map: Map{ID: 101}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
scene, ok := session.Scene()
if !ok || len(scene.ExpectedHostiles) != 3 || len(scene.BlockingHostiles) != 2 || len(scene.NPCs) != 1 || scene.Cleared {
t.Fatalf("start scene = %+v", scene)
}
if _, err := session.PreviewMoveByte(1, 0); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("preview before clear error = %v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != start || snapshot.Scene.Coordinate != start {
t.Fatalf("failed preview changed session = %+v", snapshot)
}
if _, err := session.MoveByte(1, 0); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("move before clear error = %v", err)
}
bindings := []struct {
ref HostileReference
key uint32
}{
{HostileReference{Kind: HostileMonster, Index: 0}, 9001},
{HostileReference{Kind: HostileMonster, Index: 1}, 9002},
{HostileReference{Kind: HostileAICharacter, Index: 0}, 9003},
}
for _, binding := range bindings {
if err := session.BindHostileObject(binding.ref, binding.key); err != nil {
t.Fatalf("bind %+v: %v", binding, err)
}
}
if err := session.BindHostileObject(bindings[0].ref, 9010); !errors.Is(err, ErrHostileAlreadyBound) {
t.Fatalf("duplicate hostile bind error = %v", err)
}
if err := session.BindHostileObject(HostileReference{Kind: HostileAICharacter, Index: 1}, 9011); !errors.Is(err, ErrHostileReferenceInvalid) {
t.Fatalf("neutral actor bind error = %v", err)
}
for index, binding := range bindings[:2] {
cleared, err := session.MarkHostileDefeated(binding.key)
if err != nil {
t.Fatalf("defeat %d: %v", binding.key, err)
}
if cleared != (index == 1) {
t.Fatalf("defeat %d cleared=%v", binding.key, cleared)
}
}
cleared, err := session.MarkHostileDefeated(bindings[2].key)
if err != nil || cleared {
t.Fatalf("non-blocking AI defeat cleared=%v err=%v", cleared, err)
}
if _, err := session.MarkHostileDefeated(9003); !errors.Is(err, ErrHostileAlreadyDefeated) {
t.Fatalf("duplicate defeat error = %v", err)
}
preview, err := session.PreviewMoveByte(1, 0)
if err != nil {
t.Fatalf("preview cleared transition: %v", err)
}
if preview.Map.Map.ID != 101 || !preview.Boss || !preview.Cleared {
t.Fatalf("preview target scene = %+v", preview)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != start || snapshot.Scene.Map.Map.ID != 100 {
t.Fatalf("successful preview committed transition = %+v", snapshot)
}
next, err := session.MoveByte(1, 0)
if err != nil {
t.Fatal(err)
}
if next.Map.Map.ID != 101 || !next.Boss || !next.Cleared || len(next.RuntimeObjects) != 0 {
t.Fatalf("empty boss room scene = %+v", next)
}
snapshot := session.Snapshot()
if snapshot.Run.Current.X != 1 || len(snapshot.Run.Cleared) != 2 {
t.Fatalf("snapshot = %+v", snapshot)
}
}
func TestDungeonSessionBloodAltarRoomRequiresScriptedClear(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
target := RoomCoordinate{X: 1, Y: 0}
topology := &DungeonTopology{
DungeonID: 11006,
MazeIndex: 0,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{
ID: 17020,
BloodPhases: []BloodAltarPhase{{Round: 0}},
}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: target}},
},
{x: 1, y: 0}: {
Coordinate: target,
Boss: true,
Map: &ResolvedMap{Map: Map{ID: 17021}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if scene, ok := session.Scene(); !ok || scene.Cleared {
t.Fatalf("altar scene was auto-cleared: ok=%t scene=%+v", ok, scene)
}
if _, err := session.MoveByte(1, 0); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("altar move before scripted clear error=%v", err)
}
if err := session.MarkCurrentRoomScriptedCleared(); err != nil {
t.Fatal(err)
}
if err := session.MarkCurrentRoomScriptedCleared(); err != nil {
t.Fatalf("scripted clear is not idempotent: %v", err)
}
if scene, ok := session.Scene(); !ok || !scene.Cleared {
t.Fatalf("scripted altar clear was not reflected: ok=%t scene=%+v", ok, scene)
}
if _, err := session.MoveByte(1, 0); err != nil {
t.Fatalf("move after scripted clear: %v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Status != DungeonRunActive {
t.Fatalf("scripted room clear completed run: %+v", snapshot.Run)
}
}
func TestDungeonSessionScriptedNonBlockingMonsterDoesNotHoldDoor(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
topology := &DungeonTopology{
DungeonID: 701,
MazeIndex: 0,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{ID: 200, Monsters: []MonsterSpawn{
{MonsterID: 3001}, {MonsterID: 3002},
}}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: RoomCoordinate{X: 1, Y: 0}}},
},
{x: 1, y: 0}: {
Coordinate: RoomCoordinate{X: 1, Y: 0},
Boss: true,
Map: &ResolvedMap{Map: Map{ID: 201}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
scripted := HostileReference{Kind: HostileMonster, Index: 1}
removed, err := session.MarkHostileNonBlocking(scripted)
if err != nil || !removed {
t.Fatalf("mark scripted non-blocking removed=%t err=%v", removed, err)
}
for index, reference := range []HostileReference{
{Kind: HostileMonster, Index: 0},
scripted,
} {
if err := session.BindHostileObject(reference, uint32(9100+index)); err != nil {
t.Fatal(err)
}
}
cleared, err := session.MarkHostileDefeated(9100)
if err != nil || !cleared {
t.Fatalf("ordinary blocker death cleared=%t err=%v", cleared, err)
}
scene, _ := session.Scene()
if len(scene.ExpectedHostiles) != 2 || len(scene.BlockingHostiles) != 1 || !scene.Cleared {
t.Fatalf("scene=%+v", scene)
}
if _, err := session.PreviewMoveByte(1, 0); err != nil {
t.Fatalf("preview after real blocker death: %v", err)
}
}
func TestDungeonSessionMoveFailureLeavesRunAndSceneUnchanged(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
target := RoomCoordinate{X: 1, Y: 0}
topology := &DungeonTopology{
DungeonID: 701,
MazeIndex: 0,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{ID: 100}},
},
{x: 1, y: 0}: {
Coordinate: target,
},
},
resolutionErrors: map[coordinateKey]error{{x: 1, y: 0}: ErrMapUnresolved},
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if _, err := session.MoveByte(1, 0); !errors.Is(err, ErrRoomMapUnresolved) {
t.Fatalf("move unresolved target error = %v", err)
}
snapshot := session.Snapshot()
if snapshot.Run.Current != start || snapshot.Scene.Coordinate != start || snapshot.Scene.Map.Map.ID != 100 {
t.Fatalf("failed transition changed owner state = %+v", snapshot)
}
if len(snapshot.Run.Visited) != 1 || snapshot.Run.Visited[0] != start {
t.Fatalf("failed transition changed visited rooms = %+v", snapshot.Run.Visited)
}
}
func TestDungeonSessionLayeredTransitionInstallsFreshSameCoordinateScene(t *testing.T) {
table := resolverTestTable()
table.dungeons[0].Mazes[0].MapSpecifications[0].Type = "start"
appendResolverTestMap(table, Map{
ID: 107,
Path: "map/alpha/107(0,0).map",
Monsters: []MonsterSpawn{{MonsterID: 3001}},
})
appendResolverTestMap(table, Map{
ID: 108,
Path: "map/alpha/108(0,0).map",
Monsters: []MonsterSpawn{{MonsterID: 3002}},
})
table.dungeons[0].Mazes[0].MapSpecifications = append(
table.dungeons[0].Mazes[0].MapSpecifications,
MapSpecification{
Type: "layered",
Coordinate: Point{X: 0, Y: 0},
MapIDs: []int64{107, 108},
},
)
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 700, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
coordinate := RoomCoordinate{X: 0, Y: 0}
before := session.Snapshot()
if before.Scene.Map.Map.ID != 100 || !before.Scene.Cleared || len(before.Run.Cleared) != 1 {
t.Fatalf("initial scene=%+v", before)
}
preview, err := session.PreviewLayered(coordinate, 0)
if err != nil {
t.Fatal(err)
}
if preview.Map.Map.ID != 107 || preview.Map.SpecificationType != "layered" || preview.Cleared || len(preview.ExpectedHostiles) != 1 {
t.Fatalf("layer preview=%+v", preview)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("layer preview mutated session: before=%+v after=%+v", before, after)
}
first, err := session.CommitLayered(coordinate, 0)
if err != nil {
t.Fatal(err)
}
if first.Map.Map.ID != 107 || first.Coordinate != coordinate || first.Cleared ||
len(first.RuntimeObjects) != 0 || len(first.DefeatedObjects) != 0 {
t.Fatalf("first committed layer=%+v", first)
}
afterFirst := session.Snapshot()
if afterFirst.Run.Current != coordinate || len(afterFirst.Run.Visited) != 1 || len(afterFirst.Run.Cleared) != 0 {
t.Fatalf("first layer run state=%+v", afterFirst.Run)
}
// Layer changes are cinematic state changes, not door movement: a second
// declared layer may be installed while the source layer is still uncleared.
second, err := session.CommitLayered(coordinate, 1)
if err != nil {
t.Fatal(err)
}
if second.Map.Map.ID != 108 || second.Cleared || len(second.ExpectedHostiles) != 1 {
t.Fatalf("second committed layer=%+v", second)
}
if _, err := session.PreviewLayered(RoomCoordinate{X: 1, Y: 0}, 0); !errors.Is(err, ErrLayeredCoordinateMismatch) {
t.Fatalf("cross-coordinate layer preview error=%v", err)
}
if _, err := session.PreviewLayered(coordinate, 2); !errors.Is(err, ErrLayeredIndexInvalid) {
t.Fatalf("exhausted layer preview error=%v", err)
}
if err := session.Abandon(); err != nil {
t.Fatal(err)
}
if _, err := session.CommitLayered(coordinate, 0); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("inactive run layer commit error=%v", err)
}
}
func TestDungeonSessionLayeredTransitionRequiresExplicitPVFSpecification(t *testing.T) {
table := resolverTestTable()
table.dungeons[0].Mazes[0].MapSpecifications[0].Type = "start"
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 700, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if _, err := session.PreviewLayered(RoomCoordinate{X: 0, Y: 0}, 0); !errors.Is(err, ErrLayeredSpecMissing) {
t.Fatalf("missing explicit layered specification error=%v", err)
}
}
func TestDungeonSessionStoryStagesRefreshVisitedCoordinatesWithFreshScenes(t *testing.T) {
table := resolverTestTable()
appendResolverTestMap(table, Map{ID: 107, Path: "map/alpha/107(1,0).map", Type: "[dummy]"})
appendResolverTestMap(table, Map{ID: 108, Path: "map/alpha/108(0,0).map", Type: "[dummy]"})
appendResolverTestMap(table, Map{ID: 109, Path: "map/alpha/109(1,0)boss.map", Type: "[boss]"})
maze := &table.dungeons[0].Mazes[0]
maze.QuestConnection = []int64{0, 9000, -1}
maze.Boss = &MazePoint{X: 1, Y: 0, Params: []int64{0, 0, 1, 0}}
maze.MapSpecifications = []MapSpecification{
{Type: "map", Coordinate: Point{X: 0, Y: 0}, MapIDs: []int64{108, 100}},
{Type: "map", Coordinate: Point{X: 1, Y: 0}, MapIDs: []int64{107}},
{Type: "boss", Coordinate: Point{X: 1, Y: 0}, MapIDs: []int64{109}},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 700, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
base, _ := session.Scene()
if base.Map.Map.ID != 100 || !base.Cleared {
t.Fatalf("base scene=%+v", base)
}
for stageIndex, want := range []struct {
coordinate RoomCoordinate
mapID int64
revisit bool
}{
{coordinate: RoomCoordinate{X: 1, Y: 0}, mapID: 107},
{coordinate: RoomCoordinate{X: 0, Y: 0}, mapID: 108, revisit: true},
{coordinate: RoomCoordinate{X: 1, Y: 0}, mapID: 109, revisit: true},
} {
preview, previewErr := session.PreviewStoryStage(stageIndex)
if previewErr != nil || preview.Scene.Coordinate != want.coordinate ||
preview.Scene.Map.Map.ID != want.mapID || preview.Revisit != want.revisit {
t.Fatalf("preview stage %d=%+v err=%v", stageIndex, preview, previewErr)
}
committed, commitErr := session.CommitStoryStage(stageIndex)
if commitErr != nil || committed.Scene.Coordinate != want.coordinate ||
committed.Scene.Map.Map.ID != want.mapID || committed.Revisit != want.revisit {
t.Fatalf("commit stage %d=%+v err=%v", stageIndex, committed, commitErr)
}
}
snapshot := session.Snapshot()
if snapshot.Run.Current != (RoomCoordinate{X: 1, Y: 0}) || snapshot.Scene.Map.Map.ID != 109 ||
len(snapshot.Run.Visited) != 2 {
t.Fatalf("final story snapshot=%+v", snapshot)
}
}
func TestDungeonSessionMapOverrideRefreshesVisitedCoordinateAndRestoresExactVariant(t *testing.T) {
table := resolverTestTable()
maze := &table.dungeons[0].Mazes[0]
maze.MapSpecifications = append(maze.MapSpecifications, MapSpecification{
Type: "map", Coordinate: Point{X: 2, Y: 0}, MapIDs: []int64{103},
})
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 700, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
start, _ := session.Scene()
base, err := session.MoveByteTransition(1, 0, nil)
if err != nil || base.Scene.Map.Map.ID != 101 || base.Revisit {
t.Fatalf("base transition=%+v err=%v", base, err)
}
if _, err := session.MoveByteTransition(0, 0, &start); err != nil {
t.Fatalf("return to start: %v", err)
}
hidden, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 2, Y: 0})
if err != nil || hidden.Map.ID != 103 {
t.Fatalf("hidden map=%+v err=%v", hidden, err)
}
target := RoomCoordinate{X: 1, Y: 0}
preview, err := session.PreviewMoveMapOverride(target, hidden)
if err != nil || !preview.Revisit || preview.Scene.Coordinate != target || preview.Scene.Map.Map.ID != 103 {
t.Fatalf("override preview=%+v err=%v", preview, err)
}
if _, err := session.CommitMoveMapOverride(target, hidden, &base.Scene); !errors.Is(err, ErrRevisitSceneMismatch) {
t.Fatalf("base-map cache accepted for hidden override: %v", err)
}
committed, err := session.CommitMoveMapOverride(target, hidden, nil)
if err != nil || !committed.Revisit || committed.Scene.Coordinate != target || committed.Scene.Map.Map.ID != 103 {
t.Fatalf("fresh override transition=%+v err=%v", committed, err)
}
hiddenScene := committed.Scene
if _, err := session.MoveByteTransition(0, 0, &start); err != nil {
t.Fatalf("return from hidden room: %v", err)
}
restored, err := session.CommitMoveMapOverride(target, hidden, &hiddenScene)
if err != nil || !restored.Revisit || restored.Scene.Coordinate != target || restored.Scene.Map.Map.ID != 103 {
t.Fatalf("cached hidden transition=%+v err=%v", restored, err)
}
snapshot := session.Snapshot()
if snapshot.Run.Current != target || len(snapshot.Run.Visited) != 2 {
t.Fatalf("override snapshot=%+v", snapshot)
}
}
func layeredBaseReturnTestSession(t *testing.T) (*DungeonSession, RoomCoordinate) {
t.Helper()
table := resolverTestTable()
table.dungeons[0].Mazes[0].MapSpecifications[0].Type = "start"
table.maps[0].Monsters = []MonsterSpawn{{MonsterID: 3001}}
appendResolverTestMap(table, Map{
ID: 107,
Path: "map/alpha/107(0,0).map",
Monsters: []MonsterSpawn{{MonsterID: 3002}},
})
table.dungeons[0].Mazes[0].MapSpecifications = append(
table.dungeons[0].Mazes[0].MapSpecifications,
MapSpecification{
Type: "layered",
Coordinate: Point{X: 0, Y: 0},
MapIDs: []int64{107},
},
)
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 700, 0)
if err != nil {
t.Fatal(err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
return session, RoomCoordinate{X: 0, Y: 0}
}
func TestDungeonSessionLayeredBaseReturnRestoresCachedBaseScene(t *testing.T) {
session, coordinate := layeredBaseReturnTestSession(t)
reference := HostileReference{Kind: HostileMonster, Index: 0}
if err := session.BindHostileObject(reference, 9001); err != nil {
t.Fatal(err)
}
if cleared, err := session.MarkHostileDefeated(9001); err != nil || !cleared {
t.Fatalf("clear base room: cleared=%t err=%v", cleared, err)
}
cachedBase, ok := session.Scene()
if !ok || !cachedBase.Cleared || len(cachedBase.RuntimeObjects) != 1 || len(cachedBase.DefeatedObjects) != 1 {
t.Fatalf("cached base scene=%+v", cachedBase)
}
layer, err := session.CommitLayered(coordinate, 0)
if err != nil {
t.Fatal(err)
}
if layer.Map.Map.ID != 107 || layer.Cleared {
t.Fatalf("committed layer=%+v", layer)
}
if snapshot := session.Snapshot(); len(snapshot.Run.Cleared) != 0 {
t.Fatalf("layer commit kept base clear bit=%+v", snapshot.Run)
}
beforeValidate := session.Snapshot()
validated, err := session.ValidateLayeredBase(coordinate, cachedBase)
if err != nil {
t.Fatal(err)
}
if validated.Map.Map.ID != 100 || !validated.Cleared ||
!reflect.DeepEqual(validated.RuntimeObjects, cachedBase.RuntimeObjects) ||
!reflect.DeepEqual(validated.DefeatedObjects, cachedBase.DefeatedObjects) {
t.Fatalf("validated base=%+v cached=%+v", validated, cachedBase)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, beforeValidate) {
t.Fatalf("base validation mutated session: before=%+v after=%+v", beforeValidate, after)
}
restored, err := session.CommitLayeredBase(coordinate, cachedBase)
if err != nil {
t.Fatal(err)
}
if restored.Map.Map.ID != 100 || !restored.Cleared ||
!reflect.DeepEqual(restored.RuntimeObjects, cachedBase.RuntimeObjects) ||
!reflect.DeepEqual(restored.DefeatedObjects, cachedBase.DefeatedObjects) {
t.Fatalf("restored base=%+v cached=%+v", restored, cachedBase)
}
if err := session.BindHostileObject(reference, 9010); !errors.Is(err, ErrHostileAlreadyBound) {
t.Fatalf("restored base binding was lost: %v", err)
}
if _, err := session.MarkHostileDefeated(9001); !errors.Is(err, ErrHostileAlreadyDefeated) {
t.Fatalf("restored base defeated set was lost: %v", err)
}
snapshot := session.Snapshot()
if snapshot.Run.Current != coordinate || snapshot.Scene.Map.Map.ID != 100 ||
len(snapshot.Run.Visited) != 1 || len(snapshot.Run.Cleared) != 1 {
t.Fatalf("restored base run state=%+v", snapshot)
}
}
func TestDungeonSessionLayeredBaseReturnRejectsStaleOrForeignCache(t *testing.T) {
session, coordinate := layeredBaseReturnTestSession(t)
reference := HostileReference{Kind: HostileMonster, Index: 0}
if err := session.BindHostileObject(reference, 9001); err != nil {
t.Fatal(err)
}
if cleared, err := session.MarkHostileDefeated(9001); err != nil || !cleared {
t.Fatalf("clear base room: cleared=%t err=%v", cleared, err)
}
cachedBase, ok := session.Scene()
if !ok {
t.Fatal("missing cached base scene")
}
if _, err := session.CommitLayered(coordinate, 0); err != nil {
t.Fatal(err)
}
if _, err := session.ValidateLayeredBase(RoomCoordinate{X: 1, Y: 0}, cachedBase); !errors.Is(err, ErrLayeredCoordinateMismatch) {
t.Fatalf("cross-coordinate base validation error=%v", err)
}
invalidScenes := []struct {
name string
mutate func(*DungeonRoomScene)
}{
{name: "map mismatch", mutate: func(scene *DungeonRoomScene) { scene.Map.Map.ID = 999 }},
{name: "hostile binding missing", mutate: func(scene *DungeonRoomScene) { delete(scene.RuntimeObjects, 9001) }},
{name: "clear state conflict", mutate: func(scene *DungeonRoomScene) { scene.Cleared = false }},
{name: "defeated object unbound", mutate: func(scene *DungeonRoomScene) {
scene.DefeatedObjects = append(scene.DefeatedObjects, 9999)
}},
}
for _, test := range invalidScenes {
t.Run(test.name, func(t *testing.T) {
invalid := cloneDungeonRoomScene(cachedBase)
test.mutate(&invalid)
before := session.Snapshot()
if _, err := session.ValidateLayeredBase(coordinate, invalid); !errors.Is(err, ErrRevisitSceneMismatch) {
t.Fatalf("invalid base validation error=%v", err)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("invalid base validation mutated session: before=%+v after=%+v", before, after)
}
if _, err := session.CommitLayeredBase(coordinate, invalid); !errors.Is(err, ErrRevisitSceneMismatch) {
t.Fatalf("invalid base commit error=%v", err)
}
if snapshot := session.Snapshot(); snapshot.Scene.Map.Map.ID != 107 || snapshot.Run.Current != coordinate {
t.Fatalf("invalid base commit mutated session=%+v", snapshot)
}
})
}
if err := session.Abandon(); err != nil {
t.Fatal(err)
}
if _, err := session.ValidateLayeredBase(coordinate, cachedBase); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("inactive run base validation error=%v", err)
}
if _, err := session.CommitLayeredBase(coordinate, cachedBase); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("inactive run base commit error=%v", err)
}
}
func TestDungeonSessionAICharacterOnlyRoomIsImmediatelyPassable(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
target := RoomCoordinate{X: 1, Y: 0}
topology := &DungeonTopology{
DungeonID: 703,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{
ID: 100,
AICharacters: []AICharacter{{Code: 4001, Faction: "[monster]"}},
}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: target}},
},
{x: 1, y: 0}: {
Coordinate: target,
Map: &ResolvedMap{Map: Map{ID: 101}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
scene, ok := session.Scene()
if !ok || !scene.Cleared || len(scene.ExpectedHostiles) != 1 || len(scene.BlockingHostiles) != 0 {
t.Fatalf("AI-only start scene=%+v ok=%t", scene, ok)
}
reference := HostileReference{Kind: HostileAICharacter, Index: 0}
if err := session.BindHostileObject(reference, 9001); err != nil {
t.Fatal(err)
}
cleared, err := session.MarkHostileDefeated(9001)
if err != nil || cleared {
t.Fatalf("AI death changed already-passable room: cleared=%v err=%v", cleared, err)
}
if _, err := session.PreviewMoveByte(1, 0); err != nil {
t.Fatalf("AI-only room blocked adjacent move: %v", err)
}
}
func TestDungeonSessionFriendlyMonsterTeamDoesNotBlockRoom(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 7114,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {
Coordinate: start,
Start: true,
Boss: true,
Map: &ResolvedMap{Map: Map{
ID: 76031,
Monsters: []MonsterSpawn{
{MonsterID: 75034},
{MonsterID: 75035},
{MonsterID: 75034},
},
MonsterTeam: []int64{100, 0, 100},
}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
scene, ok := session.Scene()
if !ok || scene.Cleared || len(scene.ExpectedHostiles) != 3 || len(scene.BlockingHostiles) != 2 {
t.Fatalf("team-aware scene=%+v ok=%t", scene, ok)
}
for index, objectKey := range []uint32{409, 410, 411} {
if err := session.BindHostileObject(HostileReference{Kind: HostileMonster, Index: index}, objectKey); err != nil {
t.Fatalf("bind monster %d: %v", index, err)
}
}
cleared, err := session.MarkHostileDefeated(409)
if err != nil || cleared {
t.Fatalf("first hostile death cleared=%t err=%v", cleared, err)
}
cleared, err = session.MarkHostileDefeated(411)
if err != nil || !cleared {
t.Fatalf("second hostile death cleared=%t err=%v", cleared, err)
}
if !session.Snapshot().Scene.Cleared {
t.Fatal("friendly team-0 story monster kept the room blocked")
}
}
func TestDungeonSessionDoesNotClearUntilEveryExpectedHostileIsBound(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 1,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {Coordinate: start, Start: true, Map: &ResolvedMap{Map: Map{Monsters: []MonsterSpawn{{MonsterID: 1}, {MonsterID: 2}}}}},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if err := session.BindHostileObject(HostileReference{Kind: HostileMonster, Index: 0}, 10); err != nil {
t.Fatal(err)
}
cleared, err := session.MarkHostileDefeated(10)
if err != nil || cleared {
t.Fatalf("partial binding cleared=%v err=%v", cleared, err)
}
if session.Snapshot().Scene.Cleared {
t.Fatal("partially bound room became cleared")
}
}
func TestDungeonSessionAbandonEndsActiveRun(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 702,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {Coordinate: start, Start: true, Map: &ResolvedMap{Map: Map{ID: 100}}},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if err := session.Abandon(); err != nil {
t.Fatalf("abandon active run: %v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Status != DungeonRunAbandoned {
t.Fatalf("run status = %s, want %s", snapshot.Run.Status, DungeonRunAbandoned)
}
if err := session.Abandon(); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("second abandon error = %v, want %v", err, ErrRunNotActive)
}
}
func TestDungeonSessionCompleteCurrentRoomDoesNotInventDefeatedActors(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 703,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {
Coordinate: start,
Start: true,
Boss: true,
Map: &ResolvedMap{Map: Map{
ID: 100,
Monsters: []MonsterSpawn{{MonsterID: 1}, {MonsterID: 2}},
}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if err := session.CompleteCurrentRoom(); err != nil {
t.Fatalf("complete scripted current room: %v", err)
}
snapshot := session.Snapshot()
if snapshot.Run.Status != DungeonRunCompleted || len(snapshot.Run.Cleared) != 1 || !snapshot.Scene.Cleared {
t.Fatalf("completion snapshot=%+v", snapshot)
}
if len(snapshot.Scene.DefeatedObjects) != 0 {
t.Fatalf("completion fabricated defeated objects=%v", snapshot.Scene.DefeatedObjects)
}
if err := session.CompleteCurrentRoom(); !errors.Is(err, ErrRunNotActive) {
t.Fatalf("second completion error=%v want=%v", err, ErrRunNotActive)
}
}
func TestDungeonSessionCompleteRequiresAuthoritativelyClearedCurrentRoom(t *testing.T) {
start := RoomCoordinate{}
topology := &DungeonTopology{
DungeonID: 705,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{}: {
Coordinate: start,
Start: true,
Boss: true,
Map: &ResolvedMap{Map: Map{
ID: 100,
Monsters: []MonsterSpawn{{MonsterID: 1}},
}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
if err := session.Complete(); !errors.Is(err, ErrRunCurrentRoomNotCleared) {
t.Fatalf("uncleared completion error=%v want=%v", err, ErrRunCurrentRoomNotCleared)
}
reference := HostileReference{Kind: HostileMonster, Index: 0}
if err := session.BindHostileObject(reference, 402); err != nil {
t.Fatal(err)
}
if cleared, err := session.MarkHostileDefeated(402); err != nil || !cleared {
t.Fatalf("mark final blocker cleared=%t err=%v", cleared, err)
}
if err := session.Complete(); err != nil {
t.Fatal(err)
}
if snapshot := session.Snapshot(); snapshot.Run.Status != DungeonRunCompleted || !snapshot.Scene.Cleared {
t.Fatalf("ordinary completion snapshot=%+v", snapshot)
}
}
func TestDungeonSessionRevisitRequiresAndRestoresCachedScene(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
target := RoomCoordinate{X: 1, Y: 0}
topology := &DungeonTopology{
DungeonID: 704,
Start: &start,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{
ID: 100,
Monsters: []MonsterSpawn{{MonsterID: 3001}},
}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: target}},
},
{x: 1, y: 0}: {
Coordinate: target,
Map: &ResolvedMap{Map: Map{ID: 101}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
reference := HostileReference{Kind: HostileMonster, Index: 0}
if err := session.BindHostileObject(reference, 9001); err != nil {
t.Fatal(err)
}
if cleared, err := session.MarkHostileDefeated(9001); err != nil || !cleared {
t.Fatalf("clear start room: cleared=%t err=%v", cleared, err)
}
cachedStart, ok := session.Scene()
if !ok {
t.Fatal("missing cached start scene")
}
firstPreview, err := session.PreviewMoveByteTransition(1, 0)
if err != nil {
t.Fatal(err)
}
if firstPreview.Revisit || firstPreview.Scene.Coordinate != target || firstPreview.Scene.Map.Map.ID != 101 {
t.Fatalf("first preview=%+v", firstPreview)
}
beforeFirstValidation := session.Snapshot()
validatedFirst, err := session.ValidateMoveByteTransition(1, 0, nil)
if err != nil {
t.Fatal(err)
}
if validatedFirst.Revisit || validatedFirst.Scene.Coordinate != target || validatedFirst.Scene.Map.Map.ID != 101 {
t.Fatalf("validated first transition=%+v", validatedFirst)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, beforeFirstValidation) {
t.Fatalf("first transition validation mutated session: before=%+v after=%+v", beforeFirstValidation, after)
}
if _, err := session.ValidateMoveByteTransition(1, 0, &firstPreview.Scene); !errors.Is(err, ErrRevisitSceneUnexpected) {
t.Fatalf("first validation accepted cached scene: %v", err)
}
if _, err := session.MoveByteTransition(1, 0, &firstPreview.Scene); !errors.Is(err, ErrRevisitSceneUnexpected) {
t.Fatalf("first visit accepted cached scene: %v", err)
}
firstMove, err := session.MoveByteTransition(1, 0, nil)
if err != nil {
t.Fatal(err)
}
if firstMove.Revisit || firstMove.Scene.Coordinate != target {
t.Fatalf("first move=%+v", firstMove)
}
cachedTarget := firstMove.Scene
revisitPreview, err := session.PreviewMoveByteTransition(0, 0)
if err != nil {
t.Fatal(err)
}
if !revisitPreview.Revisit || revisitPreview.Scene.Coordinate != start || revisitPreview.Scene.Map.Map.ID != 100 {
t.Fatalf("revisit preview=%+v", revisitPreview)
}
beforeRevisitValidation := session.Snapshot()
if _, err := session.ValidateMoveByteTransition(0, 0, nil); !errors.Is(err, ErrRevisitSceneRequired) {
t.Fatalf("revisit validation accepted missing scene: %v", err)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, beforeRevisitValidation) {
t.Fatalf("failed revisit validation mutated session: before=%+v after=%+v", beforeRevisitValidation, after)
}
if _, err := session.MoveByte(0, 0); !errors.Is(err, ErrRevisitSceneRequired) {
t.Fatalf("legacy MoveByte silently rebuilt revisit: %v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != target || snapshot.Scene.Coordinate != target {
t.Fatalf("missing-cache revisit mutated session=%+v", snapshot)
}
invalidScenes := []struct {
name string
mutate func(*DungeonRoomScene)
}{
{name: "map mismatch", mutate: func(scene *DungeonRoomScene) { scene.Map.Map.ID = 999 }},
{name: "hostile binding missing", mutate: func(scene *DungeonRoomScene) { delete(scene.RuntimeObjects, 9001) }},
{name: "clear state conflict", mutate: func(scene *DungeonRoomScene) { scene.Cleared = false }},
{name: "defeated object unbound", mutate: func(scene *DungeonRoomScene) {
scene.DefeatedObjects = append(scene.DefeatedObjects, 9999)
}},
}
for _, test := range invalidScenes {
t.Run(test.name, func(t *testing.T) {
invalid := cloneDungeonRoomScene(cachedStart)
test.mutate(&invalid)
before := session.Snapshot()
if _, err := session.ValidateMoveByteTransition(0, 0, &invalid); !errors.Is(err, ErrRevisitSceneMismatch) {
t.Fatalf("invalid revisit validation error=%v", err)
}
if after := session.Snapshot(); !reflect.DeepEqual(after, before) {
t.Fatalf("invalid revisit validation mutated session: before=%+v after=%+v", before, after)
}
if _, err := session.MoveByteTransition(0, 0, &invalid); !errors.Is(err, ErrRevisitSceneMismatch) {
t.Fatalf("invalid revisit error=%v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != target || snapshot.Scene.Coordinate != target {
t.Fatalf("invalid revisit mutated session=%+v", snapshot)
}
})
}
validatedRevisit, err := session.ValidateMoveByteTransition(0, 0, &cachedStart)
if err != nil {
t.Fatal(err)
}
if !validatedRevisit.Revisit ||
!reflect.DeepEqual(validatedRevisit.Scene.RuntimeObjects, cachedStart.RuntimeObjects) ||
!reflect.DeepEqual(validatedRevisit.Scene.DefeatedObjects, cachedStart.DefeatedObjects) ||
!validatedRevisit.Scene.Cleared {
t.Fatalf("validated revisit=%+v cached=%+v", validatedRevisit, cachedStart)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != target || snapshot.Scene.Coordinate != target {
t.Fatalf("valid revisit validation committed transition=%+v", snapshot)
}
restored, err := session.MoveByteTransition(0, 0, &cachedStart)
if err != nil {
t.Fatal(err)
}
if !restored.Revisit || !reflect.DeepEqual(restored.Scene.RuntimeObjects, cachedStart.RuntimeObjects) ||
!reflect.DeepEqual(restored.Scene.DefeatedObjects, cachedStart.DefeatedObjects) || !restored.Scene.Cleared {
t.Fatalf("restored scene=%+v cached=%+v", restored.Scene, cachedStart)
}
if err := session.BindHostileObject(reference, 9010); !errors.Is(err, ErrHostileAlreadyBound) {
t.Fatalf("restored binding was lost: %v", err)
}
if _, err := session.MarkHostileDefeated(9001); !errors.Is(err, ErrHostileAlreadyDefeated) {
t.Fatalf("restored defeated set was lost: %v", err)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != start || len(snapshot.Run.Cleared) != 2 {
t.Fatalf("restored run state=%+v", snapshot)
}
backAgain, err := session.MoveByteTransition(1, 0, &cachedTarget)
if err != nil {
t.Fatal(err)
}
if !backAgain.Revisit || backAgain.Scene.Coordinate != target || !backAgain.Scene.Cleared {
t.Fatalf("second revisit=%+v", backAgain)
}
}
func TestDungeonSessionRevisitRestoresPartialProgressWhenPVFAllowsEarlyMove(t *testing.T) {
start := RoomCoordinate{X: 0, Y: 0}
target := RoomCoordinate{X: 1, Y: 0}
topology := &DungeonTopology{
DungeonID: 705,
Start: &start,
AllowMoveBeforeClear: true,
rooms: map[coordinateKey]DungeonRoom{
{x: 0, y: 0}: {
Coordinate: start,
Start: true,
Map: &ResolvedMap{Map: Map{
ID: 100,
Monsters: []MonsterSpawn{{MonsterID: 3001}, {MonsterID: 3002}},
}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionRight, Coordinate: target}},
},
{x: 1, y: 0}: {
Coordinate: target,
Map: &ResolvedMap{Map: Map{ID: 101}},
Neighbors: []RoomNeighbor{{Direction: RoomDirectionLeft, Coordinate: start}},
},
},
resolutionErrors: make(map[coordinateKey]error),
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatal(err)
}
session, err := NewDungeonSession(run)
if err != nil {
t.Fatal(err)
}
for index, objectKey := range []uint32{7001, 7002} {
if err := session.BindHostileObject(HostileReference{Kind: HostileMonster, Index: index}, objectKey); err != nil {
t.Fatal(err)
}
}
if cleared, err := session.MarkHostileDefeated(7001); err != nil || cleared {
t.Fatalf("partial defeat: cleared=%t err=%v", cleared, err)
}
partial, _ := session.Scene()
if partial.Cleared || !reflect.DeepEqual(partial.DefeatedObjects, []uint32{7001}) {
t.Fatalf("partial scene=%+v", partial)
}
if _, err := session.MoveByteTransition(1, 0, nil); err != nil {
t.Fatalf("PVF early move: %v", err)
}
validatedPartial, err := session.ValidateMoveByteTransition(0, 0, &partial)
if err != nil {
t.Fatal(err)
}
if !validatedPartial.Revisit || validatedPartial.Scene.Cleared ||
!reflect.DeepEqual(validatedPartial.Scene.RuntimeObjects, partial.RuntimeObjects) ||
!reflect.DeepEqual(validatedPartial.Scene.DefeatedObjects, partial.DefeatedObjects) {
t.Fatalf("validated partial revisit=%+v", validatedPartial)
}
if snapshot := session.Snapshot(); snapshot.Run.Current != target || snapshot.Scene.Coordinate != target {
t.Fatalf("partial validation committed transition=%+v", snapshot)
}
restored, err := session.MoveByteTransition(0, 0, &partial)
if err != nil {
t.Fatal(err)
}
if !restored.Revisit || restored.Scene.Cleared ||
!reflect.DeepEqual(restored.Scene.RuntimeObjects, partial.RuntimeObjects) ||
!reflect.DeepEqual(restored.Scene.DefeatedObjects, partial.DefeatedObjects) {
t.Fatalf("partial revisit=%+v", restored)
}
if _, err := session.MarkHostileDefeated(7001); !errors.Is(err, ErrHostileAlreadyDefeated) {
t.Fatalf("partial revisit forgot old defeat: %v", err)
}
if cleared, err := session.MarkHostileDefeated(7002); err != nil || !cleared {
t.Fatalf("remaining hostile did not clear restored room: cleared=%t err=%v", cleared, err)
}
if snapshot := session.Snapshot(); !snapshot.Scene.Cleared || len(snapshot.Run.Cleared) != 2 {
t.Fatalf("final restored state=%+v", snapshot)
}
}