DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,193 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFBloodAltarTables(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify Endless and Ultimate Altar tables")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipAreas: true, CompactRuntime: true})
if err != nil {
t.Fatal(err)
}
assertBloodAltarDungeon(t, table, 11006, BloodAltarDungeonKindEndless, 15)
assertBloodAltarDungeon(t, table, 11007, BloodAltarDungeonKindUltimate, 7)
assertRealEndlessAltarFrozenRoomOrder(t, table)
}
func assertRealEndlessAltarFrozenRoomOrder(t *testing.T, table *Table) {
t.Helper()
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonLayout(resolver, 11006, 0, func(choice DungeonMapChoice) (int64, error) {
return choice.Candidates[0].ID, nil
})
if err != nil {
t.Fatalf("build real Endless Altar topology: %v", err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatalf("freeze real Endless Altar run: %v", err)
}
rooms := run.Rooms()
if len(rooms) != 3 {
t.Fatalf("real Endless Altar frozen rooms=%d want=3: %+v", len(rooms), rooms)
}
for index, room := range rooms {
want := RoomCoordinate{X: int64(index), Y: 0}
if room.Coordinate != want || room.Map == nil || room.ResolutionError != "" {
t.Fatalf("real Endless Altar room %d=%+v want coordinate=%s with resolved map", index, room, want)
}
}
bossMap := rooms[2].Map.Map
timedTemplate := int64(-1)
for index, monster := range bossMap.BloodMonsters {
if monster.MonsterCode == 61171 && monster.DurationMilliseconds == 3000 &&
monster.SpawnIntervalMilliseconds == 1000 && monster.BaseSpawnCount == 1 &&
monster.SpawnCountIncrement == 1 && monster.BatchCount == 2 {
timedTemplate = int64(index)
break
}
}
if timedTemplate < 0 {
t.Fatalf("real Endless Altar boss map %d lost timed monster 61171: %+v", bossMap.ID, bossMap.BloodMonsters)
}
foundTimedPhase := false
for _, phase := range bossMap.BloodPhases {
if phase.Round == 0 && phase.MonsterTemplateIndex == timedTemplate && phase.DelayMilliseconds == 1000 {
foundTimedPhase = true
break
}
}
if !foundTimedPhase {
t.Fatalf("real Endless Altar boss map %d lost round-11 timed phase for template %d: %+v",
bossMap.ID, timedTemplate, bossMap.BloodPhases)
}
completed := map[RoomCoordinate]struct{}{{X: 0, Y: 0}: {}}
first, ok := uniqueUnfinishedFrozenAltarNeighbor(rooms, RoomCoordinate{X: 0, Y: 0}, completed)
if !ok || first.Coordinate != (RoomCoordinate{X: 1, Y: 0}) {
t.Fatalf("real Endless Altar next after room0=%+v unique=%t", first, ok)
}
completed[RoomCoordinate{X: 1, Y: 0}] = struct{}{}
second, ok := uniqueUnfinishedFrozenAltarNeighbor(rooms, RoomCoordinate{X: 1, Y: 0}, completed)
if !ok || second.Coordinate != (RoomCoordinate{X: 2, Y: 0}) {
t.Fatalf("real Endless Altar next after rooms0/1=%+v unique=%t", second, ok)
}
}
func uniqueUnfinishedFrozenAltarNeighbor(
rooms []DungeonRoom,
current RoomCoordinate,
completed map[RoomCoordinate]struct{},
) (DungeonRoom, bool) {
byCoordinate := make(map[RoomCoordinate]DungeonRoom, len(rooms))
var source DungeonRoom
foundSource := false
for _, room := range rooms {
byCoordinate[room.Coordinate] = room
if room.Coordinate == current {
source = room
foundSource = true
}
}
if !foundSource {
return DungeonRoom{}, false
}
var result DungeonRoom
count := 0
for _, neighbor := range source.Neighbors {
room, found := byCoordinate[neighbor.Coordinate]
if !found || room.Map == nil || room.ResolutionError != "" {
continue
}
if _, complete := completed[room.Coordinate]; complete {
continue
}
result = room
count++
}
return result, count == 1
}
func assertBloodAltarDungeon(
t *testing.T,
table *Table,
dungeonID int64,
kind BloodAltarDungeonKind,
maxRounds int64,
) {
t.Helper()
dungeon, found := table.FindDungeon(dungeonID)
if !found {
t.Fatalf("blood altar dungeon %d is missing", dungeonID)
}
if dungeon.BloodAltarKind != kind || dungeon.BloodMaxRounds != maxRounds {
t.Fatalf("blood altar dungeon %d metadata = kind=%d rounds=%d", dungeonID, dungeon.BloodAltarKind, dungeon.BloodMaxRounds)
}
ownedMaps := 0
timedMonsters := 0
for _, mapValue := range table.Maps() {
if !mapOwnsDungeonForBloodAltarSmoke(mapValue, dungeonID) {
continue
}
ownedMaps++
monsters, phases := mapValue.BloodMonsters, mapValue.BloodPhases
if kind == BloodAltarDungeonKindUltimate {
monsters, phases = mapValue.UltimateMonsters, mapValue.UltimatePhases
}
if len(monsters) == 0 || len(phases) == 0 {
t.Fatalf("blood altar dungeon %d map %d lost compact typed tables: monsters=%d phases=%d diagnostics=%+v",
dungeonID, mapValue.ID, len(monsters), len(phases), mapValue.Diagnostics)
}
for _, monster := range monsters {
if monster.DurationMilliseconds > 0 {
timedMonsters++
}
}
for index, phase := range phases {
if phase.Round < 0 || phase.Round >= maxRounds || phase.MonsterTemplateIndex < 0 ||
phase.MonsterTemplateIndex >= int64(len(monsters)) {
t.Fatalf("blood altar dungeon %d map %d phase %d is out of range: %+v monsters=%d rounds=%d",
dungeonID, mapValue.ID, index, phase, len(monsters), maxRounds)
}
if kind == BloodAltarDungeonKindUltimate &&
phase.Difficulty != BloodAltarDifficultyA && phase.Difficulty != BloodAltarDifficultyB {
t.Fatalf("ultimate altar map %d phase %d difficulty = %q", mapValue.ID, index, phase.Difficulty)
}
}
}
if ownedMaps == 0 {
t.Fatalf("blood altar dungeon %d has no owned maps", dungeonID)
}
if timedMonsters == 0 {
t.Fatalf("blood altar dungeon %d has no typed timed monster definitions", dungeonID)
}
}
func mapOwnsDungeonForBloodAltarSmoke(mapValue Map, dungeonID int64) bool {
if mapValue.DungeonID.Set && mapValue.DungeonID.Value == dungeonID {
return true
}
for _, candidate := range mapValue.DungeonIDs {
if candidate == dungeonID {
return true
}
}
return false
}
@@ -0,0 +1,66 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFBossRoomEntranceConditions(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify boss-room entrance conditions")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipMaps: true, SkipAreas: true, CompactRuntime: true})
if err != nil {
t.Fatal(err)
}
for _, dungeonID := range []int64{2005, 8001} {
dungeon, found := table.FindDungeon(dungeonID)
if !found {
t.Fatalf("dungeon %d is missing", dungeonID)
}
conditions := 0
for mazeIndex, maze := range dungeon.Mazes {
condition := maze.BossRoomEntranceCondition
if condition == nil {
continue
}
conditions++
if condition.Action != "clear action" || len(condition.TimeCondition) != 2 ||
condition.TimeCondition[0] != 1 || condition.TimeCondition[1] != 0 || len(condition.HuntMonsters) != 2 {
t.Fatalf("dungeon %d maze %d condition=%+v diagnostics=%+v", dungeonID, mazeIndex, condition, maze.Diagnostics)
}
if condition.HuntMonsters[0] != (BossRoomEntranceHuntMonster{TargetID: 69201, Opaque: 0, Count: 2}) ||
condition.HuntMonsters[1] != (BossRoomEntranceHuntMonster{TargetID: 69210, Opaque: 0, Count: 2}) {
t.Fatalf("dungeon %d maze %d hunt targets=%+v", dungeonID, mazeIndex, condition.HuntMonsters)
}
}
if conditions == 0 {
t.Fatalf("dungeon %d has no typed boss-room entrance condition", dungeonID)
}
if len(dungeon.WarpMapConditions) != 2 || len(dungeon.Mazes) != 2 {
t.Fatalf("dungeon %d warp rules=%+v mazes=%d", dungeonID, dungeon.WarpMapConditions, len(dungeon.Mazes))
}
wantWarp := WarpMapCondition{
Source: Point{X: 2, Y: 1}, Destination: Point{X: 0, Y: 0},
}
for mazeIndex, maze := range dungeon.Mazes {
if len(maze.WarpMapConditions) != 1 || maze.WarpMapConditions[0] != wantWarp {
t.Fatalf("dungeon %d maze %d warp rules=%+v", dungeonID, mazeIndex, maze.WarpMapConditions)
}
}
if dungeonID == 8001 && (dungeon.Mazes[0].BossRoomEntranceCondition != nil || dungeon.Mazes[1].BossRoomEntranceCondition == nil) {
t.Fatalf("dungeon 8001 boss gate leaked across mazes: maze0=%+v maze1=%+v",
dungeon.Mazes[0].BossRoomEntranceCondition,
dungeon.Mazes[1].BossRoomEntranceCondition,
)
}
}
}
@@ -0,0 +1,280 @@
package worldmap
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
func cloneMap(in Map) Map {
in.PlayerNumber = append([]int64(nil), in.PlayerNumber...)
in.PVPStartArea = append([]int64(nil), in.PVPStartArea...)
in.DungeonIDs = append([]int64(nil), in.DungeonIDs...)
in.Tiles = append([]string(nil), in.Tiles...)
in.TileFiles = append([]TileFile(nil), in.TileFiles...)
in.TileMapRows = cloneInt64Rows(in.TileMapRows)
in.BackgroundAnimations = append([]BackgroundAnimation(nil), in.BackgroundAnimations...)
in.PathgatePositions = append([]Point(nil), in.PathgatePositions...)
in.PathgateObjects = append([]int64(nil), in.PathgateObjects...)
in.Portals = append([]Portal(nil), in.Portals...)
for i := range in.Portals {
if in.Portals[i].Position != nil {
position := *in.Portals[i].Position
in.Portals[i].Position = &position
}
}
in.Sounds = append([]string(nil), in.Sounds...)
in.AnimationObjects = append([]AnimationObject(nil), in.AnimationObjects...)
in.PassiveObjects = append([]PassiveObject(nil), in.PassiveObjects...)
in.SpecialPassiveObjects = append([]SpecialPassiveObject(nil), in.SpecialPassiveObjects...)
for i := range in.SpecialPassiveObjects {
in.SpecialPassiveObjects[i].Spawns = append([]SpecialObjectSpawn(nil), in.SpecialPassiveObjects[i].Spawns...)
in.SpecialPassiveObjects[i].HellParty = append([]HellPartyEntry(nil), in.SpecialPassiveObjects[i].HellParty...)
}
in.Monsters = append([]MonsterSpawn(nil), in.Monsters...)
in.EventMonsterPositions = append([]Point3(nil), in.EventMonsterPositions...)
in.NPCs = append([]NPCSpawn(nil), in.NPCs...)
for i := range in.NPCs {
in.NPCs[i].Params = append([]int64(nil), in.NPCs[i].Params...)
}
in.AICharacters = append([]AICharacter(nil), in.AICharacters...)
for i := range in.AICharacters {
in.AICharacters[i].Params = append([]int64(nil), in.AICharacters[i].Params...)
}
in.TournamentEnemies = append([]TournamentEnemyCandidate(nil), in.TournamentEnemies...)
in.TournamentStartAreas = append([]TournamentStartArea(nil), in.TournamentStartAreas...)
in.BloodMonsters = append([]BloodAltarMonster(nil), in.BloodMonsters...)
in.BloodPhases = append([]BloodAltarPhase(nil), in.BloodPhases...)
in.UltimateMonsters = append([]BloodAltarMonster(nil), in.UltimateMonsters...)
in.UltimatePhases = append([]BloodAltarPhase(nil), in.UltimatePhases...)
in.TownMovableAreas = append([]MovableArea(nil), in.TownMovableAreas...)
in.DungeonMovableAreas = append([]MovableArea(nil), in.DungeonMovableAreas...)
in.Summons = append([]MapSummon(nil), in.Summons...)
for i := range in.Summons {
in.Summons[i].Position = append([]int64(nil), in.Summons[i].Position...)
in.Summons[i].Info = cloneExtensions(in.Summons[i].Info)
in.Summons[i].SourceSections = cloneSections(in.Summons[i].SourceSections)
in.Summons[i].UnknownSections = cloneSections(in.Summons[i].UnknownSections)
in.Summons[i].Extensions = cloneExtensions(in.Summons[i].Extensions)
}
in.DungeonStartArea = append([]int64(nil), in.DungeonStartArea...)
in.ScreenPosition = append([]int64(nil), in.ScreenPosition...)
in.MonsterTeam = append([]int64(nil), in.MonsterTeam...)
in.PVPPracticeStartArea = append([]int64(nil), in.PVPPracticeStartArea...)
in.VirtualMovableArea = append([]int64(nil), in.VirtualMovableArea...)
in.OpeningBGM = append([]string(nil), in.OpeningBGM...)
in.AbsoluteStartPath = append([]string(nil), in.AbsoluteStartPath...)
in.SourceSections = cloneSections(in.SourceSections)
in.OpaqueSections = cloneSections(in.OpaqueSections)
in.UnknownSections = cloneSections(in.UnknownSections)
in.Extensions = cloneExtensions(in.Extensions)
in.Diagnostics = append([]Diagnostic(nil), in.Diagnostics...)
return in
}
func cloneArea(in Area) Area {
in.MapImage.Params = append([]int64(nil), in.MapImage.Params...)
in.Dungeons = append([]DungeonEntry(nil), in.Dungeons...)
in.HellQuestIDs = append([]int64(nil), in.HellQuestIDs...)
in.HellFreePassItems = append([]TicketItem(nil), in.HellFreePassItems...)
in.InfernoFreePassItems = append([]TicketItem(nil), in.InfernoFreePassItems...)
in.ParticularHellFreePassItems = append([]TicketItem(nil), in.ParticularHellFreePassItems...)
in.ItemConditions = append([]int64(nil), in.ItemConditions...)
in.SourceSections = cloneSections(in.SourceSections)
in.OpaqueSections = cloneSections(in.OpaqueSections)
in.UnknownSections = cloneSections(in.UnknownSections)
in.Extensions = cloneExtensions(in.Extensions)
in.Diagnostics = append([]Diagnostic(nil), in.Diagnostics...)
return in
}
func cloneDungeon(in Dungeon) Dungeon {
in.Metadata.CutsceneImage.Params = append([]int64(nil), in.Metadata.CutsceneImage.Params...)
in.Metadata.Champion = append([]int64(nil), in.Metadata.Champion...)
in.Metadata.PathgateObjects = append([]int64(nil), in.Metadata.PathgateObjects...)
in.Metadata.WorldMapPatternInfo = cloneSections(in.Metadata.WorldMapPatternInfo)
in.Metadata.WorldMapInfo = cloneSections(in.Metadata.WorldMapInfo)
in.Metadata.DifficultyLevels = append([]int64(nil), in.Metadata.DifficultyLevels...)
in.Metadata.DesignatedDifficulties = append([]int64(nil), in.Metadata.DesignatedDifficulties...)
in.Metadata.NamedMonsters = append([]int64(nil), in.Metadata.NamedMonsters...)
in.Metadata.RecommendedLevels = append([]int64(nil), in.Metadata.RecommendedLevels...)
in.Metadata.TournamentClearRewardExp = append([]TournamentRoundExperience(nil), in.Metadata.TournamentClearRewardExp...)
in.Metadata.TournamentResultCards = append([]TournamentResultCard(nil), in.Metadata.TournamentResultCards...)
in.Metadata.TournamentRewardItemRates = append([]TournamentRewardItemRate(nil), in.Metadata.TournamentRewardItemRates...)
in.Metadata.RequiredItems = append([]TicketItem(nil), in.Metadata.RequiredItems...)
in.Metadata.EventRequiredItems = append([]TicketItem(nil), in.Metadata.EventRequiredItems...)
in.Metadata.AddedRequiredItems = append([]TicketItem(nil), in.Metadata.AddedRequiredItems...)
in.Metadata.SpecialPassiveObjectItems = append([]SpecialPassiveObjectItem(nil), in.Metadata.SpecialPassiveObjectItems...)
in.Metadata.EventMonsters = append([]DungeonEventMonsterDefinition(nil), in.Metadata.EventMonsters...)
for index := range in.Metadata.EventMonsters {
in.Metadata.EventMonsters[index].Values = append([]int64(nil), in.Metadata.EventMonsters[index].Values...)
}
in.Metadata.IntegerValues = cloneInt64Map(in.Metadata.IntegerValues)
in.Metadata.NumberValues = cloneFloat64Map(in.Metadata.NumberValues)
in.Metadata.Flags = cloneBoolMap(in.Metadata.Flags)
if len(in.Metadata.TextValues) > 0 {
textValues := make(map[string][]string, len(in.Metadata.TextValues))
for key, value := range in.Metadata.TextValues {
textValues[key] = append([]string(nil), value...)
}
in.Metadata.TextValues = textValues
}
in.Mazes = append([]Maze(nil), in.Mazes...)
for i := range in.Mazes {
in.Mazes[i] = cloneMaze(in.Mazes[i])
}
in.WarpMapConditions = append([]WarpMapCondition(nil), in.WarpMapConditions...)
in.SourceSections = cloneSections(in.SourceSections)
in.OpaqueSections = cloneSections(in.OpaqueSections)
in.UnknownSections = cloneSections(in.UnknownSections)
in.Extensions = cloneExtensions(in.Extensions)
in.Diagnostics = append([]Diagnostic(nil), in.Diagnostics...)
return in
}
func cloneMaze(in Maze) Maze {
in.MapSpecifications = append([]MapSpecification(nil), in.MapSpecifications...)
for i := range in.MapSpecifications {
in.MapSpecifications[i].MapIDs = append([]int64(nil), in.MapSpecifications[i].MapIDs...)
}
if in.Start != nil {
start := *in.Start
start.Params = append([]int64(nil), start.Params...)
in.Start = &start
}
if in.Boss != nil {
boss := *in.Boss
boss.Params = append([]int64(nil), boss.Params...)
in.Boss = &boss
}
in.HitCount = append([]int64(nil), in.HitCount...)
in.SealDoorPosition = append([]int64(nil), in.SealDoorPosition...)
in.QuestConnection = append([]int64(nil), in.QuestConnection...)
in.SpecialPassiveObjectItems = append([]SpecialPassiveObjectItem(nil), in.SpecialPassiveObjectItems...)
in.BossMapSpecifications = cloneSections(in.BossMapSpecifications)
in.LayeredMapSpecifications = cloneSections(in.LayeredMapSpecifications)
in.BossSpecifications = cloneMapSpecifications(in.BossSpecifications)
in.LayeredSpecifications = cloneMapSpecifications(in.LayeredSpecifications)
in.RandomizedObjects = append([]RandomizedObjectScript(nil), in.RandomizedObjects...)
for index := range in.RandomizedObjects {
in.RandomizedObjects[index] = cloneRandomizedObjectScript(in.RandomizedObjects[index])
}
in.ClearConditions = append([]ClearCondition(nil), in.ClearConditions...)
in.WarpMapConditions = append([]WarpMapCondition(nil), in.WarpMapConditions...)
if in.BossRoomEntranceCondition != nil {
condition := *in.BossRoomEntranceCondition
condition.TimeCondition = append([]int64(nil), condition.TimeCondition...)
condition.HuntMonsters = append([]BossRoomEntranceHuntMonster(nil), condition.HuntMonsters...)
in.BossRoomEntranceCondition = &condition
}
in.SourceSections = cloneSections(in.SourceSections)
in.OpaqueSections = cloneSections(in.OpaqueSections)
in.UnknownSections = cloneSections(in.UnknownSections)
in.Extensions = cloneExtensions(in.Extensions)
in.Diagnostics = append([]Diagnostic(nil), in.Diagnostics...)
return in
}
func cloneMapSpecifications(in []MapSpecification) []MapSpecification {
if len(in) == 0 {
return nil
}
out := append([]MapSpecification(nil), in...)
for index := range out {
out[index].MapIDs = append([]int64(nil), out[index].MapIDs...)
}
return out
}
func cloneRandomizedObjectScript(in RandomizedObjectScript) RandomizedObjectScript {
in.Objects = append([]RidableObject(nil), in.Objects...)
for index := range in.Objects {
object := in.Objects[index]
if object.MazeCoordinate != nil {
point := *object.MazeCoordinate
object.MazeCoordinate = &point
}
if object.Position != nil {
point := *object.Position
object.Position = &point
}
object.SourceSections = cloneSections(object.SourceSections)
object.UnknownSections = cloneSections(object.UnknownSections)
object.Extensions = cloneExtensions(object.Extensions)
in.Objects[index] = object
}
in.SourceSections = cloneSections(in.SourceSections)
in.UnknownSections = cloneSections(in.UnknownSections)
in.Extensions = cloneExtensions(in.Extensions)
return in
}
func cloneExtensions(in []ExtensionSection) []ExtensionSection {
if len(in) == 0 {
return nil
}
out := make([]ExtensionSection, len(in))
for i, extension := range in {
out[i] = extension
out[i].Integers = append([]int64(nil), extension.Integers...)
out[i].Numbers = append([]float64(nil), extension.Numbers...)
out[i].Texts = append([]string(nil), extension.Texts...)
out[i].Symbols = append([]string(nil), extension.Symbols...)
out[i].Tokens = append([]dnfpvf.Token(nil), extension.Tokens...)
out[i].Scope = append([]string(nil), extension.Scope...)
}
return out
}
func cloneInt64Map(in map[string]int64) map[string]int64 {
if len(in) == 0 {
return nil
}
out := make(map[string]int64, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneFloat64Map(in map[string]float64) map[string]float64 {
if len(in) == 0 {
return nil
}
out := make(map[string]float64, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneBoolMap(in map[string]bool) map[string]bool {
if len(in) == 0 {
return nil
}
out := make(map[string]bool, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneSections(in []RawSection) []RawSection {
if len(in) == 0 {
return nil
}
out := make([]RawSection, len(in))
for i, section := range in {
out[i] = section
out[i].Tokens = append([]dnfpvf.Token(nil), section.Tokens...)
out[i].Scope = append([]string(nil), section.Scope...)
}
return out
}
func cloneInt64Rows(in [][]int64) [][]int64 {
if len(in) == 0 {
return nil
}
out := make([][]int64, len(in))
for i := range in {
out[i] = append([]int64(nil), in[i]...)
}
return out
}
@@ -0,0 +1,35 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// Luke's phase-one sanctums (dungeon/luke/phase1/sanctum_of_*.dgn) declare
// [coin limit] 8 after their last [maze info]; it must land on the dungeon.
func TestRealScriptPVFLukeSanctumCoinLimit(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify dungeon [coin limit]")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipMaps: true, SkipAreas: true, CompactRuntime: true})
if err != nil {
t.Fatal(err)
}
for _, dungeonID := range []int64{5054, 5055, 5056} {
dungeon, found := table.FindDungeon(dungeonID)
if !found {
t.Fatalf("dungeon %d is missing", dungeonID)
}
if !dungeon.Metadata.CoinLimit.Set || dungeon.Metadata.CoinLimit.Value != 8 {
t.Fatalf("dungeon %d coin limit=%+v want 8", dungeonID, dungeon.Metadata.CoinLimit)
}
}
}
@@ -0,0 +1,61 @@
package worldmap
// compactMapForRuntime removes parser evidence after all runtime fields have
// been materialized. The typed map state remains unchanged.
func compactMapForRuntime(value *Map) {
if value == nil {
return
}
value.SourceSections = nil
value.OpaqueSections = nil
value.UnknownSections = nil
value.Extensions = nil
for index := range value.Summons {
value.Summons[index].SourceSections = nil
value.Summons[index].UnknownSections = nil
value.Summons[index].Extensions = nil
}
}
func compactAreaForRuntime(value *Area) {
if value == nil {
return
}
value.SourceSections = nil
value.OpaqueSections = nil
value.UnknownSections = nil
value.Extensions = nil
}
func compactDungeonForRuntime(value *Dungeon) {
if value == nil {
return
}
value.SourceSections = nil
value.OpaqueSections = nil
value.UnknownSections = nil
value.Extensions = nil
value.Metadata.WorldMapPatternInfo = nil
value.Metadata.WorldMapInfo = nil
for mazeIndex := range value.Mazes {
maze := &value.Mazes[mazeIndex]
maze.BossMapSpecifications = nil
maze.LayeredMapSpecifications = nil
maze.SourceSections = nil
maze.OpaqueSections = nil
maze.UnknownSections = nil
maze.Extensions = nil
for scriptIndex := range maze.RandomizedObjects {
script := &maze.RandomizedObjects[scriptIndex]
script.SourceSections = nil
script.UnknownSections = nil
script.Extensions = nil
for objectIndex := range script.Objects {
object := &script.Objects[objectIndex]
object.SourceSections = nil
object.UnknownSections = nil
object.Extensions = nil
}
}
}
}
@@ -0,0 +1,100 @@
package worldmap
import (
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestCompactMapForRuntimePreservesTypedState(t *testing.T) {
value := Map{
Monsters: []MonsterSpawn{{MonsterID: 11}},
BloodMonsters: []BloodAltarMonster{{MonsterCode: 21}},
BloodPhases: []BloodAltarPhase{{Round: 2, Scale: 1.5}},
UltimateMonsters: []BloodAltarMonster{{MonsterCode: 31}},
UltimatePhases: []BloodAltarPhase{{Round: 3, Scale: 2, Difficulty: BloodAltarDifficultyB}},
SourceSections: []RawSection{{Name: "monster", Tokens: []dnfpvf.Token{{Kind: dnfpvf.TokenInt, Int: 11}}}},
OpaqueSections: []RawSection{{Name: "tile"}},
Extensions: []ExtensionSection{{Name: "tile", Tokens: []dnfpvf.Token{{Kind: dnfpvf.TokenInt, Int: 1}}}},
Summons: []MapSummon{{
Key: OptionalInt{Value: 7, Set: true},
SourceSections: []RawSection{{Name: "summon"}},
UnknownSections: []RawSection{{Name: "future"}},
Extensions: []ExtensionSection{{Name: "future"}},
}},
}
compactMapForRuntime(&value)
if len(value.Monsters) != 1 || value.Monsters[0].MonsterID != 11 ||
len(value.BloodMonsters) != 1 || value.BloodMonsters[0].MonsterCode != 21 ||
len(value.BloodPhases) != 1 || value.BloodPhases[0].Scale != 1.5 ||
len(value.UltimateMonsters) != 1 || value.UltimateMonsters[0].MonsterCode != 31 ||
len(value.UltimatePhases) != 1 || value.UltimatePhases[0].Difficulty != BloodAltarDifficultyB ||
len(value.Summons) != 1 || !value.Summons[0].Key.Set || value.Summons[0].Key.Value != 7 {
t.Fatalf("typed runtime state changed: %+v", value)
}
if value.SourceSections != nil || value.OpaqueSections != nil || value.Extensions != nil ||
value.Summons[0].SourceSections != nil || value.Summons[0].UnknownSections != nil ||
value.Summons[0].Extensions != nil {
t.Fatalf("raw parser evidence retained: %+v", value)
}
}
func TestCompactDungeonForRuntimePreservesMechanisms(t *testing.T) {
value := Dungeon{
SourceSections: []RawSection{{Name: "maze"}},
BloodAltarKind: BloodAltarDungeonKindUltimate,
BloodMaxRounds: 7,
Metadata: DungeonMetadata{WorldMapInfo: []RawSection{{Name: "worldmap info"}}},
Mazes: []Maze{{
BossSpecifications: []MapSpecification{{Type: "boss", MapIDs: []int64{101}}},
LayeredSpecifications: []MapSpecification{{Type: "layered", MapIDs: []int64{102}}},
ClearConditions: []ClearCondition{{Type: "hunt monster", TargetID: 9, Count: 1}},
BossMapSpecifications: []RawSection{{Name: "boss map specification"}},
LayeredMapSpecifications: []RawSection{{Name: "layered map specification"}},
RandomizedObjects: []RandomizedObjectScript{{
SelectCount: OptionalInt{Value: 1, Set: true},
SourceSections: []RawSection{{Name: "randomized object creation"}},
Objects: []RidableObject{{
ObjectIndex: OptionalInt{Value: 4, Set: true},
SourceSections: []RawSection{{Name: "object"}},
}},
}},
}},
}
compactDungeonForRuntime(&value)
maze := value.Mazes[0]
if value.BloodAltarKind != BloodAltarDungeonKindUltimate || value.BloodMaxRounds != 7 ||
len(maze.BossSpecifications) != 1 || len(maze.LayeredSpecifications) != 1 ||
len(maze.ClearConditions) != 1 || len(maze.RandomizedObjects) != 1 ||
!maze.RandomizedObjects[0].SelectCount.Set || !maze.RandomizedObjects[0].Objects[0].ObjectIndex.Set {
t.Fatalf("typed mechanisms changed: %+v", maze)
}
if value.SourceSections != nil || value.Metadata.WorldMapInfo != nil ||
maze.BossMapSpecifications != nil || maze.LayeredMapSpecifications != nil ||
maze.RandomizedObjects[0].SourceSections != nil ||
maze.RandomizedObjects[0].Objects[0].SourceSections != nil {
t.Fatalf("raw parser evidence retained: %+v", value)
}
}
func TestCloneMapDeepCopiesBloodAltarTables(t *testing.T) {
original := Map{
BloodMonsters: []BloodAltarMonster{{MonsterCode: 11, Position: Point3{X: 1}}},
BloodPhases: []BloodAltarPhase{{Round: 2, Scale: 1.5}},
UltimateMonsters: []BloodAltarMonster{{MonsterCode: 21, Position: Point3{X: 3}}},
UltimatePhases: []BloodAltarPhase{{Round: 4, Difficulty: BloodAltarDifficultyA}},
}
cloned := cloneMap(original)
cloned.BloodMonsters[0].Position.X = 99
cloned.BloodPhases[0].Scale = 9
cloned.UltimateMonsters[0].MonsterCode = 88
cloned.UltimatePhases[0].Difficulty = BloodAltarDifficultyB
if original.BloodMonsters[0].Position.X != 1 || original.BloodPhases[0].Scale != 1.5 ||
original.UltimateMonsters[0].MonsterCode != 21 || original.UltimatePhases[0].Difficulty != BloodAltarDifficultyA {
t.Fatalf("clone mutated original altar tables: original=%+v cloned=%+v", original, cloned)
}
}
@@ -0,0 +1,46 @@
package worldmap
import "testing"
func TestMapFileCompatibilitySectionCoverage(t *testing.T) {
sections := []string{
"map name", "player number", "pvp start area", "dungeon", "type", "greed", "tile",
"far sight scroll", "middle sight scroll", "near sight scroll", "background animation",
"pathgate pos", "sound", "animation", "passive object", "special passive object",
"monster", "event monster position", "npc", "monster specific ai", "buff", "ai character",
"fix champion", "heroes mode map index", "background correction", "background pos",
"foreground pattern alpha", "apc random point", "monster lock", "draw monster count",
"sort bottom", "add gravity", "jump power rate", "block use stackable item",
"block use active skill", "visible on dungeon clear", "loop y axis", "all dead case passable",
"disable item escape stuck", "disable character escape stuck", "cannot use coin map",
"no revival timer limit", "ignore diehard", "disable rebirth", "preserve player corpse",
"cannot use resolution change zoom", "center fixed camera", "force draw pattern", "is revival",
"is moive end", "quest start map", "hide monster", "show dust", "dungeon start area",
"screen pos", "monster team", "pvp practice start area", "virtual movable area",
"town movable area", "pathgate object", "opening bgm", "map loading image path",
"basic action", "map dialog", "dust", "absolute start path", "monster condition",
"monster spawn pos", "blood monster", "blood phase time", "ultimate monster",
"ultimate phase time", "darkness", "static player start pos", "belt scroll map",
"move layered map", "customized screen edge", "extended tile", "scroll animation",
"conditional summon monster", "map over move ani", "camera force move",
"camera edge exception", "revive with dlg", "zone defence", "tournament enemies",
"tournament start area", "before rendering info", "time line", "summon start area",
"map frame", "tile option", "background effect", "block effect", "item", "quest",
"apc create condition", "map animation", "revival map", "block path",
// The C# source spells this tag "is moive end". Accept the corrected PVF spelling too.
"is movie end",
}
if len(sections) != 101 {
t.Fatalf("compatibility section count = %d, want 101", len(sections))
}
for _, section := range sections {
if _, ok := knownMapSections[sectionKey(section)]; !ok {
t.Errorf("map section %q is not classified", section)
}
}
for _, nested := range []string{"ani info", "filename", "layer", "order", "hellparty"} {
if _, ok := knownMapSections[nested]; !ok {
t.Errorf("nested map section %q is not classified", nested)
}
}
}
@@ -0,0 +1,128 @@
package worldmap
import (
"errors"
"fmt"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var ErrMultilineStringNotClosed = errors.New("dnf worldmap multiline string is not closed")
const multilineNewlineMarker = "\x1eLONGHENG_DNF_PVF_NEWLINE\x1e"
func ParseDocument(docPath, text string) (*dnfpvf.Document, error) {
doc, err := dnfpvf.Parse(docPath, text)
if err == nil {
return doc, nil
}
normalized, changed, normalizeErr := normalizeMultilineBackticks(text)
if normalizeErr != nil {
return nil, normalizeErr
}
if !changed {
return nil, err
}
doc, retryErr := dnfpvf.Parse(docPath, normalized)
if retryErr != nil {
return nil, fmt.Errorf("parse normalized dnf pvf %q: %w", docPath, retryErr)
}
restoreMultilineNewlines(doc)
return doc, nil
}
func normalizeMultilineBackticks(text string) (string, bool, error) {
var out strings.Builder
out.Grow(len(text))
var quote byte
inBacktick := false
inComment := false
escaped := false
pendingNewlines := 0
changed := false
for pos := 0; pos < len(text); pos++ {
value := text[pos]
if inComment {
out.WriteByte(value)
if value == '\n' {
inComment = false
}
continue
}
if inBacktick {
switch value {
case '`':
out.WriteByte(value)
for pendingNewlines > 0 {
out.WriteByte('\n')
pendingNewlines--
}
inBacktick = false
case '\r':
if pos+1 < len(text) && text[pos+1] == '\n' {
continue
}
out.WriteString(multilineNewlineMarker)
pendingNewlines++
changed = true
case '\n':
out.WriteString(multilineNewlineMarker)
pendingNewlines++
changed = true
default:
out.WriteByte(value)
}
continue
}
if quote != 0 {
out.WriteByte(value)
if escaped {
escaped = false
continue
}
if value == '\\' {
escaped = true
continue
}
if value == quote {
quote = 0
}
continue
}
if value == '#' || (value == '/' && pos+1 < len(text) && text[pos+1] == '/') {
inComment = true
out.WriteByte(value)
continue
}
switch value {
case '`':
inBacktick = true
out.WriteByte(value)
case '\'', '"':
quote = value
out.WriteByte(value)
default:
out.WriteByte(value)
}
}
if inBacktick {
return "", changed, ErrMultilineStringNotClosed
}
return out.String(), changed, nil
}
func restoreMultilineNewlines(doc *dnfpvf.Document) {
if doc == nil {
return
}
for index := range doc.Tokens {
token := &doc.Tokens[index]
if token.Kind != dnfpvf.TokenString {
continue
}
token.Value = strings.ReplaceAll(token.Value, multilineNewlineMarker, "\n")
token.Raw = strings.ReplaceAll(token.Raw, multilineNewlineMarker, "\n")
}
}
@@ -0,0 +1,55 @@
package worldmap
import (
"errors"
"testing"
)
func TestParseDocumentSupportsLocalPVFMultilineBackticks(t *testing.T) {
doc, err := ParseDocument("dungeon/multiline.dgn", "[greed]\r\n`AAIIJJEE\r\n AABBHHEE` \r\n[map specification]\r\n`map` 0 0 10\r\n")
if err != nil {
t.Fatalf("parse multiline local PVF: %v", err)
}
greed, ok := doc.Text("greed")
if !ok || greed != "AAIIJJEE\n AABBHHEE" {
t.Fatalf("multiline greed = %q, %v", greed, ok)
}
if len(doc.Sections) != 2 || doc.Tokens[2].Line != 4 {
t.Fatalf("section line accounting changed: sections=%+v tokens=%+v", doc.Sections, doc.Tokens)
}
}
func TestParseDocumentLeavesCommentsAndQuotedBackticksAlone(t *testing.T) {
text := "# `comment\n// `comment too\n[name]\n\"literal ` value\"\n"
doc, err := ParseDocument("dungeon/comments.dgn", text)
if err != nil {
t.Fatalf("parse comments: %v", err)
}
if name, ok := doc.Text("name"); !ok || name != "literal ` value" {
t.Fatalf("name = %q, %v", name, ok)
}
}
func TestParseDocumentRejectsTrulyUnclosedBacktick(t *testing.T) {
_, err := ParseDocument("dungeon/bad.dgn", "[greed]\n`never closed\n")
if !errors.Is(err, ErrMultilineStringNotClosed) {
t.Fatalf("expected ErrMultilineStringNotClosed, got %v", err)
}
}
func TestRawSectionsRetainNestedLocalPVFScope(t *testing.T) {
doc := parseTestDocument(t, "map/scope.map", "[outer]\n[inner]\n7\n[/inner]\n[/outer]\n")
sections, diagnostics := rawSections(doc)
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %+v", diagnostics)
}
if len(sections) != 4 || !sections[0].Block || !sections[1].Block {
t.Fatalf("sections = %+v", sections)
}
if len(sections[1].Scope) != 1 || sections[1].Scope[0] != "outer" {
t.Fatalf("inner scope = %+v", sections[1].Scope)
}
if len(sections[2].Scope) != 1 || sections[2].Scope[0] != "outer" {
t.Fatalf("inner closing scope = %+v", sections[2].Scope)
}
}
@@ -0,0 +1,203 @@
package worldmap
import (
"errors"
"fmt"
)
var (
ErrMapChoiceRequired = errors.New("dnf dungeon map choice is required")
ErrMapChoiceRejected = errors.New("dnf dungeon map choice is not a candidate")
)
type DungeonMapChoice struct {
DungeonID int64 `json:"dungeon_id"`
MazeIndex int `json:"maze_index"`
Coordinate RoomCoordinate `json:"coordinate"`
FileType MapFileType `json:"file_type"`
Source ResolutionSource `json:"source"`
Candidates []Map `json:"candidates"`
}
type DungeonMapChooser func(choice DungeonMapChoice) (mapID int64, err error)
func BuildDungeonLayout(
resolver *Resolver,
dungeonID int64,
mazeIndex int,
chooser DungeonMapChooser,
) (*DungeonTopology, error) {
topology, err := BuildDungeonTopology(resolver, dungeonID, mazeIndex)
if err != nil {
return nil, err
}
dungeonIndex := resolver.byDungeon[dungeonID]
maze := resolver.table.dungeons[dungeonIndex.dungeonPos].Mazes[mazeIndex]
for key, room := range topology.rooms {
if room.Map != nil {
continue
}
request := ResolveRequest{
DungeonID: dungeonID, MazeIndex: mazeIndex,
X: room.Coordinate.X, Y: room.Coordinate.Y,
}
candidates, candidateErr := resolver.ResolveCandidates(request)
if candidateErr != nil && !errors.Is(candidateErr, ErrMapUnresolved) {
return nil, candidateErr
}
fileType := expectedMapFileType(maze, key)
source := ResolutionSource("")
if len(candidates) > 0 {
source = candidates[0].Source
} else {
pool, poolFileType, poolErr := dungeonLayoutPoolCandidates(
resolver,
dungeonID,
mazeIndex,
fileType,
room,
)
if poolErr != nil {
// PVF special dungeons can expose logical greed/boss coordinates
// without assigning a MAP resource to every cell. The current
// client only needs the entrance MAP during SELECT_DUNGEON; later
// mechanism/warp movement resolves its target independently. Keep
// the parser evidence on non-start cells instead of inventing a
// type-pool map or rejecting the entire dungeon up front.
if !room.Start && errors.Is(poolErr, ErrMapPoolEmpty) {
continue
}
return nil, fmt.Errorf(
"assign dungeon room map %s: coordinate_error=%v pool_error=%w",
room.Coordinate, candidateErr, poolErr,
)
}
source = ResolutionTypePool
fileType = poolFileType
candidates = make([]ResolvedMap, 0, len(pool))
for _, mapValue := range pool {
candidates = append(candidates, ResolvedMap{Map: mapValue, Source: source})
}
}
choice := DungeonMapChoice{
DungeonID: dungeonID, MazeIndex: mazeIndex, Coordinate: room.Coordinate,
FileType: fileType, Source: source, Candidates: resolvedCandidateMaps(candidates),
}
selected, selectErr := selectDungeonMapCandidate(choice, chooser)
if selectErr != nil {
return nil, selectErr
}
room.Map = &selected
room.ResolutionError = ""
topology.rooms[key] = room
delete(topology.resolutionErrors, key)
topology.Diagnostics = removeRoomResolutionDiagnostic(topology.Diagnostics, room.Coordinate)
}
return topology, nil
}
func dungeonLayoutPoolCandidates(
resolver *Resolver,
dungeonID int64,
mazeIndex int,
fileType MapFileType,
room DungeonRoom,
) ([]Map, MapFileType, error) {
request := MapPoolRequest{DungeonID: dungeonID, MazeIndex: mazeIndex, FileType: fileType}
pool, err := resolver.PoolCandidates(request)
if err == nil {
return pool, fileType, nil
}
for _, fallbackType := range dungeonLayoutFallbackPoolFileTypes(fileType, room) {
fallbackRequest := MapPoolRequest{DungeonID: dungeonID, MazeIndex: mazeIndex, FileType: fallbackType}
fallbackPool, fallbackErr := resolver.PoolCandidates(fallbackRequest)
if fallbackErr == nil {
return fallbackPool, fallbackType, nil
}
}
return nil, fileType, err
}
func dungeonLayoutFallbackPoolFileTypes(fileType MapFileType, room DungeonRoom) []MapFileType {
if fileType != MapFileBoss || !room.Start || !room.Boss {
return nil
}
// Tower-style single-room floors declare the only room as both start and
// boss in the .dgn, while the owned .map is a normal uncoordinated map
// (for example towerofdespair_down/15130despair001.map). Prefer the real
// dungeon-owned normal pool; accept a start pool as a narrow fallback for
// the same start==boss shape.
return []MapFileType{MapFileNormal, MapFileStart}
}
func selectDungeonMapCandidate(choice DungeonMapChoice, chooser DungeonMapChooser) (ResolvedMap, error) {
if len(choice.Candidates) == 0 {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d room=%s file_type=%s",
ErrMapPoolEmpty, choice.DungeonID, choice.MazeIndex, choice.Coordinate, choice.FileType,
)
}
if len(choice.Candidates) == 1 && chooser == nil {
return ResolvedMap{Map: cloneMap(choice.Candidates[0]), Source: choice.Source}, nil
}
if chooser == nil {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d room=%s file_type=%s candidates=%v",
ErrMapChoiceRequired, choice.DungeonID, choice.MazeIndex, choice.Coordinate,
choice.FileType, mapIDs(choice.Candidates),
)
}
mapID, err := chooser(cloneDungeonMapChoice(choice))
if err != nil {
return ResolvedMap{}, err
}
for _, candidate := range choice.Candidates {
if candidate.ID == mapID {
return ResolvedMap{Map: cloneMap(candidate), Source: choice.Source}, nil
}
}
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d room=%s map_id=%d candidates=%v",
ErrMapChoiceRejected, choice.DungeonID, choice.MazeIndex, choice.Coordinate,
mapID, mapIDs(choice.Candidates),
)
}
func resolvedCandidateMaps(values []ResolvedMap) []Map {
out := make([]Map, 0, len(values))
for _, value := range values {
out = append(out, cloneMap(value.Map))
}
return out
}
func cloneDungeonMapChoice(in DungeonMapChoice) DungeonMapChoice {
candidates := in.Candidates
in.Candidates = make([]Map, len(candidates))
for index, candidate := range candidates {
in.Candidates[index] = cloneMap(candidate)
}
return in
}
func mapIDs(values []Map) []int64 {
out := make([]int64, len(values))
for index, value := range values {
out[index] = value.ID
}
return out
}
func removeRoomResolutionDiagnostic(values []TopologyDiagnostic, coordinate RoomCoordinate) []TopologyDiagnostic {
out := values[:0]
for _, diagnostic := range values {
if diagnostic.Code == "room_map_unresolved" && diagnostic.Coordinate != nil &&
diagnostic.Coordinate.X == coordinate.X && diagnostic.Coordinate.Y == coordinate.Y {
continue
}
out = append(out, diagnostic)
}
return out
}
@@ -0,0 +1,287 @@
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
}
@@ -0,0 +1,28 @@
package worldmap
// ReplaceMonster resolves a configured placeholder in one private run layout.
// Coordinates, rank, drops and all other PVF fields stay with the map spawn.
// Neither the shared resolver nor another player's topology is mutated.
func (t *DungeonTopology) ReplaceMonster(dummy, monster, level int64) int {
if t == nil || dummy <= 0 || monster <= 0 || level <= 0 {
return 0
}
count := 0
for key, room := range t.rooms {
if room.Map == nil {
continue
}
prepared := cloneDungeonRoom(room)
for i := range prepared.Map.Map.Monsters {
spawn := &prepared.Map.Map.Monsters[i]
if spawn.MonsterID == dummy {
spawn.MonsterID = monster
spawn.Level = level
spawn.AutoLevel = 0
count++
}
}
t.rooms[key] = prepared
}
return count
}
@@ -0,0 +1,699 @@
package worldmap
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
)
var (
ErrRunTopologyRequired = errors.New("dnf dungeon run topology is required")
ErrRunStartMissing = errors.New("dnf dungeon run start room is missing")
ErrRunNotActive = errors.New("dnf dungeon run is not active")
ErrRunCurrentRoomNotCleared = errors.New("dnf dungeon run current room is not cleared")
ErrRunTargetVisitChanged = errors.New("dnf dungeon run target visit state changed")
ErrRoomCoordinateMalformed = errors.New("dnf dungeon room coordinate is malformed")
ErrRoomNotFound = errors.New("dnf dungeon room is not in topology")
ErrRoomNotAdjacent = errors.New("dnf dungeon room is not adjacent")
ErrRoomMapUnresolved = errors.New("dnf dungeon room map is unresolved")
ErrLayeredCoordinateMismatch = errors.New("dnf dungeon layered map coordinate does not match the current room")
ErrLayeredTransitionChanged = errors.New("dnf dungeon layered map transition changed")
ErrLayeredBaseMismatch = errors.New("dnf dungeon layered base map does not match the cached room")
ErrStoryStageTransitionChanged = errors.New("dnf dungeon story stage transition changed")
ErrMapOverrideTransitionChanged = errors.New("dnf dungeon map-override transition changed")
)
type DungeonRunStatus string
const (
DungeonRunActive DungeonRunStatus = "active"
DungeonRunCompleted DungeonRunStatus = "completed"
DungeonRunAbandoned DungeonRunStatus = "abandoned"
)
type DungeonRunSnapshot struct {
DungeonID int64 `json:"dungeon_id"`
MazeIndex int `json:"maze_index"`
Status DungeonRunStatus `json:"status"`
Current RoomCoordinate `json:"current"`
Visited []RoomCoordinate `json:"visited,omitempty"`
Cleared []RoomCoordinate `json:"cleared,omitempty"`
}
type DungeonRun struct {
mu sync.RWMutex
topology *DungeonTopology
current coordinateKey
visited map[coordinateKey]struct{}
cleared map[coordinateKey]struct{}
status DungeonRunStatus
}
func ParseRoomCoordinate(value string) (RoomCoordinate, error) {
value = strings.TrimSpace(value)
xText, yText, ok := strings.Cut(value, ":")
if !ok || xText == "" || yText == "" || strings.Contains(yText, ":") {
return RoomCoordinate{}, fmt.Errorf("%w: %q", ErrRoomCoordinateMalformed, value)
}
x, err := strconv.ParseInt(xText, 10, 64)
if err != nil {
return RoomCoordinate{}, fmt.Errorf("%w: %q", ErrRoomCoordinateMalformed, value)
}
y, err := strconv.ParseInt(yText, 10, 64)
if err != nil {
return RoomCoordinate{}, fmt.Errorf("%w: %q", ErrRoomCoordinateMalformed, value)
}
return RoomCoordinate{X: x, Y: y}, nil
}
func NewDungeonRun(topology *DungeonTopology) (*DungeonRun, error) {
if topology == nil {
return nil, ErrRunTopologyRequired
}
if topology.Start == nil {
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrRunStartMissing, topology.DungeonID, topology.MazeIndex)
}
start := *topology.Start
room, ok := topology.Room(start)
if !ok {
return nil, fmt.Errorf("%w: dungeon=%d maze=%d room=%s", ErrRoomNotFound, topology.DungeonID, topology.MazeIndex, start)
}
if room.Map == nil {
return nil, roomMapError(topology, start)
}
key := coordinateKey{x: start.X, y: start.Y}
return &DungeonRun{
topology: topology,
current: key,
visited: map[coordinateKey]struct{}{key: {}},
cleared: make(map[coordinateKey]struct{}),
status: DungeonRunActive,
}, nil
}
func (r *DungeonRun) CurrentRoom() (DungeonRoom, bool) {
if r == nil {
return DungeonRoom{}, false
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.topology.Room(RoomCoordinate{X: r.current.x, Y: r.current.y})
}
// Rooms returns the resolved immutable room layout selected for this run.
// Callers receive cloned room values and cannot mutate the run topology.
func (r *DungeonRun) Rooms() []DungeonRoom {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.topology.Rooms()
}
func (r *DungeonRun) MoveByte(nextX, nextY byte) (DungeonRoom, error) {
return r.MoveTo(RoomCoordinate{X: int64(nextX), Y: int64(nextY)})
}
// PreviewMoveByte validates a room transition without changing the run.
func (r *DungeonRun) PreviewMoveByte(nextX, nextY byte) (DungeonRoom, error) {
return r.PreviewMoveTo(RoomCoordinate{X: int64(nextX), Y: int64(nextY)})
}
func (r *DungeonRun) MoveRoomID(roomID string) (DungeonRoom, error) {
coordinate, err := ParseRoomCoordinate(roomID)
if err != nil {
return DungeonRoom{}, err
}
return r.MoveTo(coordinate)
}
// PreviewMoveTo validates a room transition without changing current, visited,
// cleared, or completion state.
func (r *DungeonRun) PreviewMoveTo(target RoomCoordinate) (DungeonRoom, error) {
room, _, _, err := r.previewMoveToVisit(target)
return room, err
}
// previewStoryStage validates one PVF-declared activity-story stage as
// adjacent room travel, then replaces the topology's base map descriptor with
// the exact ordered story candidate. A previously visited coordinate remains
// a fresh story scene; the revisit bit is used only to select client payload
// mode 2.
func (r *DungeonRun) previewStoryStage(stageIndex int) (DungeonRoom, bool, error) {
if r == nil {
return DungeonRoom{}, false, ErrRunTopologyRequired
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolveStoryStageLocked(stageIndex)
}
func (r *DungeonRun) commitStoryStage(
stageIndex int,
expectedCoordinate RoomCoordinate,
expectedMapID int64,
expectedRevisit bool,
targetCleared bool,
) (DungeonRoom, error) {
if r == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
room, revisit, err := r.resolveStoryStageLocked(stageIndex)
if err != nil {
return DungeonRoom{}, err
}
actualMapID := int64(0)
if room.Map != nil {
actualMapID = room.Map.Map.ID
}
if room.Coordinate != expectedCoordinate || actualMapID != expectedMapID || revisit != expectedRevisit {
return DungeonRoom{}, fmt.Errorf(
"%w: stage=%d expected=%s/%d revisit=%t actual=%s/%d revisit=%t",
ErrStoryStageTransitionChanged,
stageIndex,
expectedCoordinate,
expectedMapID,
expectedRevisit,
room.Coordinate,
actualMapID,
revisit,
)
}
targetKey := coordinateKey{x: room.Coordinate.X, y: room.Coordinate.Y}
r.current = targetKey
r.visited[targetKey] = struct{}{}
delete(r.cleared, targetKey)
if targetCleared {
r.cleared[targetKey] = struct{}{}
}
return room, nil
}
func (r *DungeonRun) resolveStoryStageLocked(stageIndex int) (DungeonRoom, bool, error) {
if r.topology == nil || r.topology.resolver == nil {
return DungeonRoom{}, false, ErrResolverTableRequired
}
if r.status != DungeonRunActive {
return DungeonRoom{}, false, fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
resolved, stage, err := r.topology.resolver.ResolveStoryStage(
r.topology.DungeonID,
r.topology.MazeIndex,
stageIndex,
)
if err != nil {
return DungeonRoom{}, false, err
}
targetKey := coordinateKey{x: stage.Coordinate.X, y: stage.Coordinate.Y}
room, ok := r.topology.rooms[targetKey]
if !ok {
return DungeonRoom{}, false, fmt.Errorf("%w: room=%s", ErrRoomNotFound, stage.Coordinate)
}
if abs64(stage.Coordinate.X-r.current.x)+abs64(stage.Coordinate.Y-r.current.y) != 1 {
return DungeonRoom{}, false, fmt.Errorf(
"%w: current=%s target=%s story_stage=%d",
ErrRoomNotAdjacent,
RoomCoordinate{X: r.current.x, Y: r.current.y},
stage.Coordinate,
stageIndex,
)
}
if !r.topology.AllowMoveBeforeClear {
if _, ok := r.cleared[r.current]; !ok {
return DungeonRoom{}, false, fmt.Errorf(
"%w: room=%s story_stage=%d",
ErrRunCurrentRoomNotCleared,
RoomCoordinate{X: r.current.x, Y: r.current.y},
stageIndex,
)
}
}
_, revisit := r.visited[targetKey]
room.Map = &resolved
return r.topology.prepareRoom(room), revisit, nil
}
// previewCurrentLayered resolves a PVF-declared layer for the current room
// without requiring the source room to be clear. Current clients request this
// same-coordinate transition from cinematic [CHANGE MAP] with MOVE_MAP kind 1.
func (r *DungeonRun) previewCurrentLayered(coordinate RoomCoordinate, layerIndex int) (DungeonRoom, error) {
if r == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolveCurrentLayeredLocked(coordinate, layerIndex)
}
// commitCurrentLayered revalidates the exact previewed map and replaces the
// coordinate's prior clear bit with the fresh layer's state. Visited/current
// coordinates do not change because a layered transition is not room travel.
func (r *DungeonRun) commitCurrentLayered(
coordinate RoomCoordinate,
layerIndex int,
expectedMapID int64,
targetCleared bool,
) (DungeonRoom, error) {
if r == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
room, err := r.resolveCurrentLayeredLocked(coordinate, layerIndex)
if err != nil {
return DungeonRoom{}, err
}
if room.Map == nil || room.Map.Map.ID != expectedMapID {
actualMapID := int64(0)
if room.Map != nil {
actualMapID = room.Map.Map.ID
}
return DungeonRoom{}, fmt.Errorf(
"%w: room=%s layer=%d expected_map=%d actual_map=%d",
ErrLayeredTransitionChanged,
coordinate,
layerIndex,
expectedMapID,
actualMapID,
)
}
delete(r.cleared, r.current)
if targetCleared {
r.cleared[r.current] = struct{}{}
}
return room, nil
}
func (r *DungeonRun) resolveCurrentLayeredLocked(
coordinate RoomCoordinate,
layerIndex int,
) (DungeonRoom, error) {
if r.topology == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
if r.status != DungeonRunActive {
return DungeonRoom{}, fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
current := RoomCoordinate{X: r.current.x, Y: r.current.y}
if coordinate != current {
return DungeonRoom{}, fmt.Errorf(
"%w: current=%s requested=%s",
ErrLayeredCoordinateMismatch,
current,
coordinate,
)
}
room, ok := r.topology.rooms[r.current]
if !ok {
return DungeonRoom{}, fmt.Errorf("%w: room=%s", ErrRoomNotFound, current)
}
if r.topology.resolver == nil {
return DungeonRoom{}, ErrResolverTableRequired
}
resolved, err := r.topology.resolver.ResolveLayered(
r.topology.DungeonID,
r.topology.MazeIndex,
coordinate,
layerIndex,
)
if err != nil {
return DungeonRoom{}, err
}
room.Map = &resolved
return r.topology.prepareRoom(room), nil
}
// previewCurrentLayeredBase resolves the topology-owned base room for the
// current coordinate without mutating the run. A layered transition never
// changes the current coordinate, so the topology entry remains the base room
// that the explicit PVF layer list temporarily replaced. The coordinate's
// current clear bit is reported for diagnostics only: after a layered commit
// it describes the layer, not the base room.
func (r *DungeonRun) previewCurrentLayeredBase(coordinate RoomCoordinate) (DungeonRoom, bool, error) {
if r == nil {
return DungeonRoom{}, false, ErrRunTopologyRequired
}
r.mu.RLock()
defer r.mu.RUnlock()
room, err := r.resolveCurrentLayeredBaseLocked(coordinate)
if err != nil {
return DungeonRoom{}, false, err
}
_, cleared := r.cleared[r.current]
return room, cleared, nil
}
// commitCurrentLayeredBase revalidates the exact topology-owned base map and
// replaces the coordinate's clear bit with the restored base room's state.
// Visited/current coordinates do not change because a layered base return is
// not room travel.
func (r *DungeonRun) commitCurrentLayeredBase(
coordinate RoomCoordinate,
expectedMapID int64,
targetCleared bool,
) (DungeonRoom, error) {
if r == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
room, err := r.resolveCurrentLayeredBaseLocked(coordinate)
if err != nil {
return DungeonRoom{}, err
}
if room.Map == nil || room.Map.Map.ID != expectedMapID {
actualMapID := int64(0)
if room.Map != nil {
actualMapID = room.Map.Map.ID
}
return DungeonRoom{}, fmt.Errorf(
"%w: room=%s expected_base_map=%d actual_base_map=%d",
ErrLayeredBaseMismatch,
coordinate,
expectedMapID,
actualMapID,
)
}
delete(r.cleared, r.current)
if targetCleared {
r.cleared[r.current] = struct{}{}
}
return room, nil
}
func (r *DungeonRun) resolveCurrentLayeredBaseLocked(coordinate RoomCoordinate) (DungeonRoom, error) {
if r.topology == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
if r.status != DungeonRunActive && r.status != DungeonRunCompleted {
return DungeonRoom{}, fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
current := RoomCoordinate{X: r.current.x, Y: r.current.y}
if coordinate != current {
return DungeonRoom{}, fmt.Errorf(
"%w: current=%s requested=%s",
ErrLayeredCoordinateMismatch,
current,
coordinate,
)
}
room, ok := r.topology.rooms[r.current]
if !ok {
return DungeonRoom{}, fmt.Errorf("%w: room=%s", ErrRoomNotFound, current)
}
if room.Map == nil {
return DungeonRoom{}, roomMapError(r.topology, current)
}
return r.topology.prepareRoom(room), nil
}
// previewMoveToVisit validates a transition and reports the persistent run
// state for its target under the same read lock. DungeonSession uses this to
// distinguish a first visit from a revisit without treating rebuilt PVF scene
// data as prior runtime state.
func (r *DungeonRun) previewMoveToVisit(target RoomCoordinate) (DungeonRoom, bool, bool, error) {
if r == nil {
return DungeonRoom{}, false, false, ErrRunTopologyRequired
}
r.mu.RLock()
defer r.mu.RUnlock()
room, err := r.validateMoveLocked(target)
if err != nil {
return DungeonRoom{}, false, false, err
}
targetKey := coordinateKey{x: target.X, y: target.Y}
_, visited := r.visited[targetKey]
_, cleared := r.cleared[targetKey]
return room, visited, cleared, nil
}
// previewMoveMapOverride validates ordinary adjacent room travel while
// replacing only the target room's map template. The target coordinate remains
// the client-requested logical room; mechanisms such as Gent Infiltrate use a
// hidden PVF coordinate solely as the source of the replacement map.
func (r *DungeonRun) previewMoveMapOverride(
target RoomCoordinate,
resolved ResolvedMap,
) (DungeonRoom, bool, bool, error) {
if r == nil {
return DungeonRoom{}, false, false, ErrRunTopologyRequired
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.resolveMoveMapOverrideLocked(target, resolved)
}
func (r *DungeonRun) commitMoveMapOverride(
target RoomCoordinate,
resolved ResolvedMap,
expectedMapID int64,
expectedRevisit bool,
targetCleared bool,
) (DungeonRoom, error) {
if r == nil {
return DungeonRoom{}, ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
room, revisit, _, err := r.resolveMoveMapOverrideLocked(target, resolved)
if err != nil {
return DungeonRoom{}, err
}
actualMapID := int64(0)
if room.Map != nil {
actualMapID = room.Map.Map.ID
}
if room.Coordinate != target || actualMapID != expectedMapID || revisit != expectedRevisit {
return DungeonRoom{}, fmt.Errorf(
"%w: expected=%s/%d revisit=%t actual=%s/%d revisit=%t",
ErrMapOverrideTransitionChanged,
target,
expectedMapID,
expectedRevisit,
room.Coordinate,
actualMapID,
revisit,
)
}
targetKey := coordinateKey{x: target.X, y: target.Y}
r.current = targetKey
r.visited[targetKey] = struct{}{}
delete(r.cleared, targetKey)
if targetCleared {
r.cleared[targetKey] = struct{}{}
}
return room, nil
}
func (r *DungeonRun) resolveMoveMapOverrideLocked(
target RoomCoordinate,
resolved ResolvedMap,
) (DungeonRoom, bool, bool, error) {
if resolved.Map.ID <= 0 {
return DungeonRoom{}, false, false, fmt.Errorf(
"%w: room=%s map=%d",
ErrMapReferenceMissing,
target,
resolved.Map.ID,
)
}
room, err := r.validateMoveLocked(target)
if err != nil {
return DungeonRoom{}, false, false, err
}
copy := resolved
copy.Map = cloneMap(resolved.Map)
room.Map = &copy
targetKey := coordinateKey{x: target.X, y: target.Y}
_, revisit := r.visited[targetKey]
_, cleared := r.cleared[targetKey]
return room, revisit, cleared, nil
}
func (r *DungeonRun) MoveTo(target RoomCoordinate) (DungeonRoom, error) {
return r.moveTo(target, false)
}
func (r *DungeonRun) moveTo(target RoomCoordinate, clearTarget bool) (DungeonRoom, error) {
room, _, err := r.moveToVisit(target, clearTarget, nil)
return room, err
}
// moveToVisit commits a transition and can require the target's first/revisit
// classification to match a prior preview. The optional expectation keeps a
// cached-room restore fail-closed if another owner mutated DungeonRun directly
// between preview and commit.
func (r *DungeonRun) moveToVisit(target RoomCoordinate, clearTarget bool, expectedRevisit *bool) (DungeonRoom, bool, error) {
if r == nil {
return DungeonRoom{}, false, ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
room, err := r.validateMoveLocked(target)
if err != nil {
return DungeonRoom{}, false, err
}
targetKey := coordinateKey{x: target.X, y: target.Y}
_, revisit := r.visited[targetKey]
if expectedRevisit != nil && revisit != *expectedRevisit {
return DungeonRoom{}, revisit, fmt.Errorf(
"%w: room=%s expected_revisit=%t actual_revisit=%t",
ErrRunTargetVisitChanged,
target,
*expectedRevisit,
revisit,
)
}
r.current = targetKey
r.visited[targetKey] = struct{}{}
if clearTarget {
r.cleared[targetKey] = struct{}{}
}
return room, revisit, nil
}
func (r *DungeonRun) validateMoveLocked(target RoomCoordinate) (DungeonRoom, error) {
if r.status != DungeonRunActive {
return DungeonRoom{}, fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
targetKey := coordinateKey{x: target.X, y: target.Y}
room, ok := r.topology.rooms[targetKey]
if !ok {
return DungeonRoom{}, fmt.Errorf("%w: room=%s", ErrRoomNotFound, target)
}
adjacent := abs64(target.X-r.current.x)+abs64(target.Y-r.current.y) == 1
if !adjacent && !r.topology.allowsWarpTransition(r.current, targetKey) {
return DungeonRoom{}, fmt.Errorf(
"%w: current=%s target=%s",
ErrRoomNotAdjacent, RoomCoordinate{X: r.current.x, Y: r.current.y}, target,
)
}
if !r.topology.AllowMoveBeforeClear {
if _, ok := r.cleared[r.current]; !ok {
return DungeonRoom{}, fmt.Errorf(
"%w: room=%s",
ErrRunCurrentRoomNotCleared, RoomCoordinate{X: r.current.x, Y: r.current.y},
)
}
}
if room.Map == nil {
return DungeonRoom{}, roomMapError(r.topology, target)
}
return r.topology.prepareRoom(room), nil
}
func (r *DungeonRun) MarkCurrentRoomCleared() error {
if r == nil {
return ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
if r.status != DungeonRunActive {
return fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
r.cleared[r.current] = struct{}{}
return nil
}
func (r *DungeonRun) Complete() error {
if r == nil {
return ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
if r.status != DungeonRunActive {
return fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
if _, ok := r.cleared[r.current]; !ok {
return fmt.Errorf(
"%w: room=%s",
ErrRunCurrentRoomNotCleared, RoomCoordinate{X: r.current.x, Y: r.current.y},
)
}
r.status = DungeonRunCompleted
return nil
}
// CompleteCurrentRoom records an authoritative scripted completion event for
// the active room. Unlike Complete, it does not require every blocking actor to
// have reported a death: some PVF tutorial finals end by escape/cinematic while
// ordinary actors are still present. It marks only the room/run lifecycle and
// never invents defeated runtime objects.
func (r *DungeonRun) CompleteCurrentRoom() error {
if r == nil {
return ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
if r.status != DungeonRunActive {
return fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
r.cleared[r.current] = struct{}{}
r.status = DungeonRunCompleted
return nil
}
func (r *DungeonRun) Abandon() error {
if r == nil {
return ErrRunTopologyRequired
}
r.mu.Lock()
defer r.mu.Unlock()
if r.status != DungeonRunActive {
return fmt.Errorf("%w: status=%s", ErrRunNotActive, r.status)
}
r.status = DungeonRunAbandoned
return nil
}
func (r *DungeonRun) Snapshot() DungeonRunSnapshot {
if r == nil {
return DungeonRunSnapshot{}
}
r.mu.RLock()
defer r.mu.RUnlock()
snapshot := DungeonRunSnapshot{
DungeonID: r.topology.DungeonID,
MazeIndex: r.topology.MazeIndex,
Status: r.status,
Current: RoomCoordinate{X: r.current.x, Y: r.current.y},
Visited: sortedCoordinates(r.visited),
Cleared: sortedCoordinates(r.cleared),
}
return snapshot
}
func roomMapError(topology *DungeonTopology, coordinate RoomCoordinate) error {
key := coordinateKey{x: coordinate.X, y: coordinate.Y}
if cause := topology.resolutionErrors[key]; cause != nil {
return fmt.Errorf("%w: room=%s: %w", ErrRoomMapUnresolved, coordinate, cause)
}
return fmt.Errorf("%w: room=%s", ErrRoomMapUnresolved, coordinate)
}
func sortedCoordinates(values map[coordinateKey]struct{}) []RoomCoordinate {
coordinates := make([]RoomCoordinate, 0, len(values))
for key := range values {
coordinates = append(coordinates, RoomCoordinate{X: key.x, Y: key.y})
}
sort.Slice(coordinates, func(i, j int) bool {
if coordinates[i].Y != coordinates[j].Y {
return coordinates[i].Y < coordinates[j].Y
}
return coordinates[i].X < coordinates[j].X
})
return coordinates
}
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
@@ -0,0 +1,108 @@
package worldmap
var dungeonCoreSections = stringSet(
"name", "explain", "cutscene image", "minimap image", "entering title",
"minimum required level", "basis level", "experience increasing point", "background pos",
"dungeon type", "champion", "pathgate object", "worldmap pattern info", "worldmap info",
"difficulty", "difficulty level", "designate dungeon difficulty", "tutorial dungeon", "no fatigue",
"named monster", "recommended level", "limit party count",
"lost land parameters", "limit time", "inferno quest", "special passive object item",
)
var dungeonIntegerSections = stringSet(
"hell dungeon", "escape hell", "coin limit", "hell coin limit", "character coin limit",
"hell party required item count",
"party member coin limit", "limit inout count", "limit escape character", "gold card use",
"join cost gold", "gold drop prob", "fatigue", "fatigue result", "prohibit practice",
"shared difficult dungeon index", "impossible dungeon classification", "ai character appear rate",
"dummy appear count", "party num check", "quest npc dungeon", "herosmode enable",
"herosmode required quest", "event dungeon difficulty", "event dungeon cof",
"adjust mob exp by level", "blood max round", "mob level charac level replace flag",
"tower fp cubepiece", "tower limit of stackable item",
"tower max clear item num", "tower item drop", "tower random map indexes", "warroom map index",
"max monster", "spawn step max", "battle spawn time", "player kc",
"tournament round fatigue", "tournament clear reward gold rate", "monster random appear only",
"remain monster count visible",
)
var dungeonNumberSections = stringSet("kill count const", "move speed")
var dungeonFlagSections = stringSet(
// The tower sections are BARE FLAGS in the DGN, exactly like [no fatigue]:
// the real dungeon/Towers/ZTowerOfDespair001.dgn carries "[tower of despair]"
// with no value. Reading them as integer sections loses the identity, and
// without it the whole tower clear/settlement path is unreachable.
"tower of despair", "tower of grief",
"defense dungeon", "blood dungeon", "dimension dungeon", "tournament dungeon",
"powerwar dungeon", "risk dungeon", "ancient dungeon", "event dungeon",
"crack of dimension dungeon", "disable exit", "individual map movement",
"open door even enemy", "move map even enemy", "enter without fatigue",
"use fatigue only start dungeon", "special dungeon", "kronos dungeon", "sao dungeon",
"no check enter boss room", "no giveup panalty", "boss mark disable", "enable apc stackable",
"movable even boss die", "ignore clear effect and sound", "dont kill mob when boss die",
"remove dungeon score and rank", "no revival timer limit", "multi start point",
"ignore default dungeon clear", "inferno dungeon", "select dungeon normal disable",
)
var dungeonTextSections = stringSet(
"revision table", "monsterapc diff table", "entering title next", "monster level rivision",
"dungeon type for extra drop", "minimap icon",
)
var opaqueDungeonSections = stringSet(
"required item", "event required item", "added required item",
"coin info", "schedule", "event monster", "event monster2", "event monster3",
"event monster random map", "monster difficulty bonus", "tower dialog", "tower stage",
"tower recovery", "tower high skill initial cool time", "tower high skill initial cool time rate",
"death tower map indexes", "result card", "reward item rate", "tournament clear reward exp",
"clear map", "clear reward item", "boss room entrance condition", "named monster map pos",
"warp map condition", "dungeon minimap icon setting", "realdungeon checkup",
"common passive object", "on clear add passive object", "appendage destory object",
"linked dungeon", "clear action", "point by type", "dungeon exp bonus monster",
"clear_party_buff_card", "advance altar type", "advance altar map",
"advance altar clear reward", "advance altar survival map", "advance altar survival clear reward",
"rank type", "fever time", "nateram time attack info",
"village attack revenge dungeon", "time spiral", "spicies dungeon", "summon monster",
"monster type spawn prob", "monster type spawn cost", "monster type spawn interval rate",
"monster spawn base interval", "monster spawn random interval", "spawn common monster index",
"spawn common champion index", "spawn super champion index", "spawn boss index",
"spawn step resource pool", "common monster item drop prob", "common champion item drop prob",
"super champion item drop prob", "boss item drop prob", "common monster exp const",
"common champion exp const", "super champion exp const", "boss exp const",
"common monster item drop list", "common champion item drop list",
"super champion item drop list", "boss item drop list", "monster exp bonus per user decrease",
"result exp bonus per user decrease", "evil", "evil high level", "evil rate", "easy",
"medium", "hard", "round", "list", "type", "object type", "hide grid",
"on guide movie dungeon", "recommend party", "recommend equipment", "minimap boss icon",
"sway effect", "free difficulty", "entry dungeon", "adjust hp gauge", "necessary party",
)
var knownMazeSections = stringSet(
"maze info", "size", "greed", "maze chance rate", "inferno special", "escape hell", "map specification", "start map", "boss map", "hit count",
"seal door appear rate", "seal door map index", "seal door pos", "quest connection",
"special passive object item",
"randomized object creation", "clear condition", "boss map specification",
"layered map specification", "select", "regenerate", "object", "map", "index", "pos",
"team", "minimap icon", "monster", "neutral", "character", "destroy object", "seeking", "hunt monster",
"hunt apc", "hunt boss",
)
var opaqueMazeSections = stringSet(
"randomized object creation", "clear condition", "boss map specification",
"layered map specification", "select", "regenerate", "object", "map", "index", "pos",
"team", "minimap icon", "monster", "neutral", "character", "destroy object", "seeking", "hunt monster",
"hunt apc", "hunt boss",
)
func isDungeonMetadataSection(name string) bool {
key := sectionKey(name)
for _, set := range []map[string]struct{}{
dungeonCoreSections, dungeonIntegerSections, dungeonNumberSections,
dungeonFlagSections, dungeonTextSections, opaqueDungeonSections,
} {
if _, ok := set[key]; ok {
return true
}
}
return false
}
@@ -0,0 +1,980 @@
package worldmap
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
)
var (
ErrDungeonRunRequired = errors.New("dnf dungeon run is required")
ErrHostileReferenceInvalid = errors.New("dnf dungeon hostile reference is invalid")
ErrRuntimeObjectKeyInvalid = errors.New("dnf dungeon runtime object key is invalid")
ErrRuntimeObjectKeyBound = errors.New("dnf dungeon runtime object key is already bound")
ErrHostileAlreadyBound = errors.New("dnf dungeon hostile is already bound")
ErrHostileNotBound = errors.New("dnf dungeon hostile runtime object is not bound")
ErrHostileAlreadyDefeated = errors.New("dnf dungeon hostile is already defeated")
ErrRevisitSceneRequired = errors.New("dnf dungeon revisit scene is required")
ErrRevisitSceneUnexpected = errors.New("dnf dungeon revisit scene was supplied for a first visit")
ErrRevisitSceneMismatch = errors.New("dnf dungeon revisit scene does not match persistent room state")
)
type HostileKind string
const (
HostileMonster HostileKind = "monster"
HostileAICharacter HostileKind = "ai_character"
)
type HostileReference struct {
Kind HostileKind `json:"kind"`
Index int `json:"index"`
}
type DungeonRoomScene struct {
Coordinate RoomCoordinate `json:"coordinate"`
Start bool `json:"start,omitempty"`
Boss bool `json:"boss,omitempty"`
Map ResolvedMap `json:"map"`
Monsters []MonsterSpawn `json:"monsters,omitempty"`
NPCs []NPCSpawn `json:"npcs,omitempty"`
AICharacters []AICharacter `json:"ai_characters,omitempty"`
PassiveObjects []PassiveObject `json:"passive_objects,omitempty"`
SpecialPassiveObjects []SpecialPassiveObject `json:"special_passive_objects,omitempty"`
Portals []Portal `json:"portals,omitempty"`
Summons []MapSummon `json:"summons,omitempty"`
ExpectedHostiles []HostileReference `json:"expected_hostiles,omitempty"`
BlockingHostiles []HostileReference `json:"blocking_hostiles,omitempty"`
RuntimeObjects map[uint32]HostileReference `json:"runtime_objects,omitempty"`
DefeatedObjects []uint32 `json:"defeated_objects,omitempty"`
Cleared bool `json:"cleared,omitempty"`
}
type DungeonSessionSnapshot struct {
Run DungeonRunSnapshot `json:"run"`
Scene DungeonRoomScene `json:"scene"`
}
// DungeonRoomTransition describes a validated room target. Revisit is derived
// only from DungeonRun's persistent visited set; callers must not infer it from
// a packet cache or from the target room's PVF contents.
type DungeonRoomTransition struct {
Scene DungeonRoomScene `json:"scene"`
Revisit bool `json:"revisit,omitempty"`
}
type validatedDungeonRoomTransition struct {
transition DungeonRoomTransition
bindings map[HostileReference]uint32
defeated map[uint32]struct{}
}
type DungeonSession struct {
mu sync.RWMutex
run *DungeonRun
scene DungeonRoomScene
bindings map[HostileReference]uint32
defeated map[uint32]struct{}
}
func NewDungeonSession(run *DungeonRun) (*DungeonSession, error) {
if run == nil {
return nil, ErrDungeonRunRequired
}
room, ok := run.CurrentRoom()
if !ok {
return nil, ErrRoomNotFound
}
session := &DungeonSession{run: run}
if err := session.loadRoom(room); err != nil {
return nil, err
}
return session, nil
}
func (s *DungeonSession) Scene() (DungeonRoomScene, bool) {
if s == nil || s.run == nil {
return DungeonRoomScene{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
return cloneDungeonRoomScene(s.scene), true
}
// Rooms exposes the run's frozen map choices without exposing its mutable
// visit/clear state. Party orchestration uses this to give every member the
// leader's exact PVF map selection.
func (s *DungeonSession) Rooms() []DungeonRoom {
if s == nil {
return nil
}
s.mu.RLock()
run := s.run
s.mu.RUnlock()
if run == nil {
return nil
}
return run.Rooms()
}
func (s *DungeonSession) BindHostileObject(reference HostileReference, objectKey uint32) error {
if s == nil || s.run == nil {
return ErrDungeonRunRequired
}
if objectKey == 0 {
return ErrRuntimeObjectKeyInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if !containsHostileReference(s.scene.ExpectedHostiles, reference) {
return fmt.Errorf("%w: kind=%s index=%d", ErrHostileReferenceInvalid, reference.Kind, reference.Index)
}
if previous, ok := s.bindings[reference]; ok {
return fmt.Errorf("%w: kind=%s index=%d object_key=%d", ErrHostileAlreadyBound, reference.Kind, reference.Index, previous)
}
if previous, ok := s.scene.RuntimeObjects[objectKey]; ok {
return fmt.Errorf("%w: object_key=%d kind=%s index=%d", ErrRuntimeObjectKeyBound, objectKey, previous.Kind, previous.Index)
}
s.bindings[reference] = objectKey
s.scene.RuntimeObjects[objectKey] = reference
return nil
}
// MarkHostileNonBlocking keeps a scripted actor in the room's runtime object
// table while excluding it from the ordinary all-monsters-dead door scan.
func (s *DungeonSession) MarkHostileNonBlocking(reference HostileReference) (bool, error) {
if s == nil || s.run == nil {
return false, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
if !containsHostileReference(s.scene.ExpectedHostiles, reference) {
return false, fmt.Errorf("%w: kind=%s index=%d", ErrHostileReferenceInvalid, reference.Kind, reference.Index)
}
removed := false
blocking := s.scene.BlockingHostiles[:0]
for _, candidate := range s.scene.BlockingHostiles {
if candidate == reference {
removed = true
continue
}
blocking = append(blocking, candidate)
}
if !removed {
return false, nil
}
s.scene.BlockingHostiles = blocking
if len(s.scene.BlockingHostiles) == 0 && !s.scene.Cleared {
if err := s.run.MarkCurrentRoomCleared(); err != nil {
return false, err
}
s.scene.Cleared = true
}
return true, nil
}
func (s *DungeonSession) MarkHostileDefeated(objectKey uint32) (bool, error) {
if s == nil || s.run == nil {
return false, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.scene.RuntimeObjects[objectKey]; !ok {
return false, fmt.Errorf("%w: object_key=%d", ErrHostileNotBound, objectKey)
}
if _, ok := s.defeated[objectKey]; ok {
return false, fmt.Errorf("%w: object_key=%d", ErrHostileAlreadyDefeated, objectKey)
}
s.defeated[objectKey] = struct{}{}
s.scene.DefeatedObjects = append(s.scene.DefeatedObjects, objectKey)
if s.scene.Cleared {
return false, nil
}
for _, reference := range s.scene.BlockingHostiles {
boundObjectKey, ok := s.bindings[reference]
if !ok {
return false, nil
}
if _, defeated := s.defeated[boundObjectKey]; !defeated {
return false, nil
}
}
if err := s.run.MarkCurrentRoomCleared(); err != nil {
return false, err
}
s.scene.Cleared = true
return true, nil
}
// PreviewMoveByte resolves and validates the target room without changing the
// active scene or any DungeonRun state. Packet owners must use this before
// writing a room-transition response.
func (s *DungeonSession) PreviewMoveByte(nextX, nextY byte) (DungeonRoomScene, error) {
transition, err := s.PreviewMoveByteTransition(nextX, nextY)
return transition.Scene, err
}
// PreviewLayered validates a same-coordinate PVF layer without mutating the
// run, scene, runtime bindings, defeated set, or current-room clear state.
// Source-room clearance is intentionally not required: the client can request
// a cinematic layer while scripted source actors remain alive.
func (s *DungeonSession) PreviewLayered(
coordinate RoomCoordinate,
layerIndex int,
) (DungeonRoomScene, error) {
if s == nil || s.run == nil {
return DungeonRoomScene{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.previewLayeredLocked(coordinate, layerIndex)
}
// PreviewStoryStage validates the next ordered activity-story map without
// mutating the run. Revisit reports whether the target coordinate was already
// rendered; the returned scene is always freshly built from the stage map.
func (s *DungeonSession) PreviewStoryStage(stageIndex int) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
room, revisit, err := s.run.previewStoryStage(stageIndex)
if err != nil {
return DungeonRoomTransition{}, err
}
scene, err := dungeonRoomScene(room)
if err != nil {
return DungeonRoomTransition{}, err
}
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, nil
}
// CommitStoryStage installs a fresh ordered story scene. Even when its
// coordinate was visited earlier, cached base/stage actors are never restored;
// payload mode 2 makes the client refresh that coordinate with this new map.
func (s *DungeonSession) CommitStoryStage(stageIndex int) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
room, revisit, err := s.run.previewStoryStage(stageIndex)
if err != nil {
return DungeonRoomTransition{}, err
}
scene, err := dungeonRoomScene(room)
if err != nil {
return DungeonRoomTransition{}, err
}
if _, err := s.run.commitStoryStage(
stageIndex,
scene.Coordinate,
scene.Map.Map.ID,
revisit,
scene.Cleared,
); err != nil {
return DungeonRoomTransition{}, err
}
s.installSceneLocked(scene)
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: revisit}, nil
}
// CommitLayered installs a fresh scene at the current coordinate and resets
// all object bindings and defeats from the previous layer. DungeonRun's clear
// bit is also reset (or recomputed for an intrinsically empty target layer),
// so an already-cleared source cannot make the target immediately passable.
func (s *DungeonSession) CommitLayered(
coordinate RoomCoordinate,
layerIndex int,
) (DungeonRoomScene, error) {
if s == nil || s.run == nil {
return DungeonRoomScene{}, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
scene, err := s.previewLayeredLocked(coordinate, layerIndex)
if err != nil {
return DungeonRoomScene{}, err
}
if _, err := s.run.commitCurrentLayered(
coordinate,
layerIndex,
scene.Map.Map.ID,
scene.Cleared,
); err != nil {
return DungeonRoomScene{}, err
}
s.installSceneLocked(scene)
return cloneDungeonRoomScene(s.scene), nil
}
func (s *DungeonSession) previewLayeredLocked(
coordinate RoomCoordinate,
layerIndex int,
) (DungeonRoomScene, error) {
room, err := s.run.previewCurrentLayered(coordinate, layerIndex)
if err != nil {
return DungeonRoomScene{}, err
}
scene, err := dungeonRoomScene(room)
if err != nil {
return DungeonRoomScene{}, err
}
return scene, nil
}
// ValidateLayeredBase validates a caller-owned cached base scene for the
// current coordinate after the explicit PVF layer list is exhausted. The
// cached scene must match the topology-owned base map; a stale or foreign
// cache fails closed. The run, active scene, runtime bindings, and defeated
// set are not mutated.
func (s *DungeonSession) ValidateLayeredBase(
coordinate RoomCoordinate,
cachedScene DungeonRoomScene,
) (DungeonRoomScene, error) {
if s == nil || s.run == nil {
return DungeonRoomScene{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
scene, _, _, err := s.validateLayeredBaseLocked(coordinate, cachedScene)
if err != nil {
return DungeonRoomScene{}, err
}
return cloneDungeonRoomScene(scene), nil
}
// CommitLayeredBase restores a caller-owned cached base scene after the
// explicit PVF layer list is exhausted. Like the revisit restore path, the
// cached runtime bindings and defeats are reinstalled so the base room's
// prior combat state survives the layered detour; the coordinate's clear bit
// is replaced with the validated base room state.
func (s *DungeonSession) CommitLayeredBase(
coordinate RoomCoordinate,
cachedScene DungeonRoomScene,
) (DungeonRoomScene, error) {
if s == nil || s.run == nil {
return DungeonRoomScene{}, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
scene, bindings, defeated, err := s.validateLayeredBaseLocked(coordinate, cachedScene)
if err != nil {
return DungeonRoomScene{}, err
}
if _, err := s.run.commitCurrentLayeredBase(coordinate, scene.Map.Map.ID, scene.Cleared); err != nil {
return DungeonRoomScene{}, err
}
s.installRestoredSceneLocked(scene, bindings, defeated)
return cloneDungeonRoomScene(s.scene), nil
}
func (s *DungeonSession) validateLayeredBaseLocked(
coordinate RoomCoordinate,
cachedScene DungeonRoomScene,
) (DungeonRoomScene, map[HostileReference]uint32, map[uint32]struct{}, error) {
baseRoom, _, err := s.run.previewCurrentLayeredBase(coordinate)
if err != nil {
return DungeonRoomScene{}, nil, nil, err
}
expected, err := dungeonRoomScene(baseRoom)
if err != nil {
return DungeonRoomScene{}, nil, nil, err
}
// A layered commit replaced the coordinate's clear bit with the layer's
// state, so the cached base scene is the only surviving record of the base
// room's clear state. The revisit validator's strict structural checks
// still apply; its persistent clear-state input is the cached scene itself.
return validateRevisitDungeonRoomScene(expected, cachedScene, cachedScene.Cleared)
}
// PreviewMoveByteTransition additionally reports whether the target was
// already visited. Its Scene is the current PVF definition used to validate a
// caller-owned cached scene; it intentionally contains no reconstructed prior
// runtime objects.
func (s *DungeonSession) PreviewMoveByteTransition(nextX, nextY byte) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
transition, _, err := s.previewMoveByteTransitionLocked(nextX, nextY)
return transition, err
}
// PreviewMoveMapOverride validates an ordinary room move but projects a
// server-selected PVF map at the requested logical coordinate. Revisit remains
// coordinate-owned; callers use it to choose a full refresh when this is the
// first visit to the replacement map after the base room was already visited.
func (s *DungeonSession) PreviewMoveMapOverride(
target RoomCoordinate,
resolved ResolvedMap,
) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
room, revisit, _, err := s.run.previewMoveMapOverride(target, resolved)
if err != nil {
return DungeonRoomTransition{}, err
}
scene, err := dungeonRoomScene(room)
if err != nil {
return DungeonRoomTransition{}, err
}
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, nil
}
// ValidateMoveMapOverride applies the complete fresh/exact-cache validation
// without changing the run or active scene.
func (s *DungeonSession) ValidateMoveMapOverride(
target RoomCoordinate,
resolved ResolvedMap,
cachedScene *DungeonRoomScene,
) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
validated, err := s.validateMoveMapOverrideLocked(target, resolved, cachedScene)
if err != nil {
return DungeonRoomTransition{}, err
}
validated.transition.Scene = cloneDungeonRoomScene(validated.transition.Scene)
return validated.transition, nil
}
// CommitMoveMapOverride installs either a fresh replacement scene or the exact
// cached scene for that replacement map. A nil cachedScene intentionally means
// fresh even when the logical coordinate was visited with a different map.
func (s *DungeonSession) CommitMoveMapOverride(
target RoomCoordinate,
resolved ResolvedMap,
cachedScene *DungeonRoomScene,
) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
validated, err := s.validateMoveMapOverrideLocked(target, resolved, cachedScene)
if err != nil {
return DungeonRoomTransition{}, err
}
scene := validated.transition.Scene
revisit := validated.transition.Revisit
if _, err := s.run.commitMoveMapOverride(
target,
resolved,
scene.Map.Map.ID,
revisit,
scene.Cleared,
); err != nil {
return DungeonRoomTransition{}, err
}
if cachedScene != nil {
s.installRestoredSceneLocked(scene, validated.bindings, validated.defeated)
} else {
s.installSceneLocked(scene)
}
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: revisit}, nil
}
func (s *DungeonSession) validateMoveMapOverrideLocked(
target RoomCoordinate,
resolved ResolvedMap,
cachedScene *DungeonRoomScene,
) (validatedDungeonRoomTransition, error) {
room, revisit, targetCleared, err := s.run.previewMoveMapOverride(target, resolved)
if err != nil {
return validatedDungeonRoomTransition{}, err
}
expected, err := dungeonRoomScene(room)
if err != nil {
return validatedDungeonRoomTransition{}, err
}
validated := validatedDungeonRoomTransition{
transition: DungeonRoomTransition{Scene: expected, Revisit: revisit},
bindings: make(map[HostileReference]uint32, len(expected.ExpectedHostiles)),
defeated: make(map[uint32]struct{}, len(expected.ExpectedHostiles)),
}
if cachedScene == nil {
return validated, nil
}
if !revisit {
return validatedDungeonRoomTransition{}, fmt.Errorf(
"%w: room=%s map=%d",
ErrRevisitSceneUnexpected,
expected.Coordinate,
expected.Map.Map.ID,
)
}
validated.transition.Scene, validated.bindings, validated.defeated, err = validateRevisitDungeonRoomScene(
expected,
*cachedScene,
targetCleared,
)
if err != nil {
return validatedDungeonRoomTransition{}, err
}
return validated, nil
}
func (s *DungeonSession) previewMoveByteTransitionLocked(nextX, nextY byte) (DungeonRoomTransition, bool, error) {
target := RoomCoordinate{X: int64(nextX), Y: int64(nextY)}
room, revisit, targetCleared, err := s.run.previewMoveToVisit(target)
if err != nil {
return DungeonRoomTransition{}, false, err
}
scene, err := dungeonRoomScene(room)
if err != nil {
return DungeonRoomTransition{}, false, err
}
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, targetCleared, nil
}
func (s *DungeonSession) MoveByte(nextX, nextY byte) (DungeonRoomScene, error) {
transition, err := s.MoveByteTransition(nextX, nextY, nil)
return transition.Scene, err
}
// ValidateMoveByteTransition runs the complete first-visit/revisit validation
// used by MoveByteTransition without changing the run, active scene, runtime
// bindings, or defeated set. Packet owners call this before writing op29.
func (s *DungeonSession) ValidateMoveByteTransition(
nextX, nextY byte,
revisitScene *DungeonRoomScene,
) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.RLock()
defer s.mu.RUnlock()
validated, err := s.validateMoveByteTransitionLocked(nextX, nextY, revisitScene)
if err != nil {
return DungeonRoomTransition{}, err
}
validated.transition.Scene = cloneDungeonRoomScene(validated.transition.Scene)
return validated.transition, nil
}
// MoveByteTransition commits a first visit from PVF data or restores a caller-
// owned cached scene for a revisit. A revisit without its exact prior scene is
// rejected so the persistent DungeonRun.clear state can never be combined
// with freshly respawned actors.
func (s *DungeonSession) MoveByteTransition(
nextX, nextY byte,
revisitScene *DungeonRoomScene,
) (DungeonRoomTransition, error) {
if s == nil || s.run == nil {
return DungeonRoomTransition{}, ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
validated, err := s.validateMoveByteTransitionLocked(nextX, nextY, revisitScene)
if err != nil {
return DungeonRoomTransition{}, err
}
scene := validated.transition.Scene
expectedRevisit := validated.transition.Revisit
if _, _, err := s.run.moveToVisit(scene.Coordinate, scene.Cleared, &expectedRevisit); err != nil {
return DungeonRoomTransition{}, err
}
if validated.transition.Revisit {
s.installRestoredSceneLocked(scene, validated.bindings, validated.defeated)
} else {
s.installSceneLocked(scene)
}
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: validated.transition.Revisit}, nil
}
func (s *DungeonSession) validateMoveByteTransitionLocked(
nextX, nextY byte,
revisitScene *DungeonRoomScene,
) (validatedDungeonRoomTransition, error) {
preview, targetCleared, err := s.previewMoveByteTransitionLocked(nextX, nextY)
if err != nil {
return validatedDungeonRoomTransition{}, err
}
validated := validatedDungeonRoomTransition{
transition: preview,
bindings: make(map[HostileReference]uint32, len(preview.Scene.ExpectedHostiles)),
defeated: make(map[uint32]struct{}, len(preview.Scene.ExpectedHostiles)),
}
if preview.Revisit {
if revisitScene == nil {
return validatedDungeonRoomTransition{}, fmt.Errorf(
"%w: room=%s map=%d",
ErrRevisitSceneRequired,
preview.Scene.Coordinate,
preview.Scene.Map.Map.ID,
)
}
validated.transition.Scene, validated.bindings, validated.defeated, err = validateRevisitDungeonRoomScene(
preview.Scene,
*revisitScene,
targetCleared,
)
if err != nil {
return validatedDungeonRoomTransition{}, err
}
} else if revisitScene != nil {
return validatedDungeonRoomTransition{}, fmt.Errorf(
"%w: room=%s map=%d",
ErrRevisitSceneUnexpected,
preview.Scene.Coordinate,
preview.Scene.Map.Map.ID,
)
}
return validated, nil
}
// Abandon ends the active run while keeping DungeonRun ownership behind the
// session lock. Callers use this for an explicit mid-dungeon return to town.
func (s *DungeonSession) Abandon() error {
if s == nil || s.run == nil {
return ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
return s.run.Abandon()
}
// CompleteCurrentRoom commits an authoritative scripted final-room completion
// without fabricating actor deaths. The bridge must validate the final-room
// protocol event before calling this lifecycle method.
func (s *DungeonSession) CompleteCurrentRoom() error {
if s == nil || s.run == nil {
return ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.run.CompleteCurrentRoom(); err != nil {
return err
}
s.scene.Cleared = true
return nil
}
// MarkCurrentRoomScriptedCleared records a server-owned scripted room clear
// without completing the dungeon run or fabricating deaths for ordinary
// hostile actors. Multi-room mechanisms such as the Blood/Ultimate Altars use
// this after their map-local round table has been exhausted.
func (s *DungeonSession) MarkCurrentRoomScriptedCleared() error {
if s == nil || s.run == nil {
return ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
if s.scene.Cleared {
return nil
}
if err := s.run.MarkCurrentRoomCleared(); err != nil {
return err
}
s.scene.Cleared = true
return nil
}
// Complete closes an ordinary final room after its blocking actors have
// authoritatively cleared it. Unlike CompleteCurrentRoom, this method does not
// bypass the run's current-room-cleared precondition.
func (s *DungeonSession) Complete() error {
if s == nil || s.run == nil {
return ErrDungeonRunRequired
}
s.mu.Lock()
defer s.mu.Unlock()
return s.run.Complete()
}
func (s *DungeonSession) Snapshot() DungeonSessionSnapshot {
if s == nil || s.run == nil {
return DungeonSessionSnapshot{}
}
s.mu.RLock()
defer s.mu.RUnlock()
return DungeonSessionSnapshot{Run: s.run.Snapshot(), Scene: cloneDungeonRoomScene(s.scene)}
}
func (s *DungeonSession) loadRoom(room DungeonRoom) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadRoomLocked(room)
}
func (s *DungeonSession) loadRoomLocked(room DungeonRoom) error {
scene, err := dungeonRoomScene(room)
if err != nil {
return err
}
if scene.Cleared {
if err := s.run.MarkCurrentRoomCleared(); err != nil {
return err
}
}
s.installSceneLocked(scene)
return nil
}
func (s *DungeonSession) installSceneLocked(scene DungeonRoomScene) {
s.scene = scene
s.bindings = make(map[HostileReference]uint32, len(scene.ExpectedHostiles))
s.defeated = make(map[uint32]struct{}, len(scene.ExpectedHostiles))
}
func (s *DungeonSession) installRestoredSceneLocked(
scene DungeonRoomScene,
bindings map[HostileReference]uint32,
defeated map[uint32]struct{},
) {
s.scene = scene
s.bindings = bindings
s.defeated = defeated
}
func validateRevisitDungeonRoomScene(
expected DungeonRoomScene,
cached DungeonRoomScene,
runCleared bool,
) (DungeonRoomScene, map[HostileReference]uint32, map[uint32]struct{}, error) {
if !sameDungeonRoomPVFScene(expected, cached) {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s expected_map=%d cached_room=%s cached_map=%d reason=pvf_scene_changed",
ErrRevisitSceneMismatch,
expected.Coordinate,
expected.Map.Map.ID,
cached.Coordinate,
cached.Map.Map.ID,
)
}
restored := cloneDungeonRoomScene(cached)
bindings := make(map[HostileReference]uint32, len(restored.RuntimeObjects))
for objectKey, reference := range restored.RuntimeObjects {
if objectKey == 0 || !containsHostileReference(restored.ExpectedHostiles, reference) {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s object_key=%d kind=%s index=%d reason=invalid_runtime_binding",
ErrRevisitSceneMismatch,
restored.Coordinate,
objectKey,
reference.Kind,
reference.Index,
)
}
if previous, exists := bindings[reference]; exists {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s kind=%s index=%d object_key=%d previous_object_key=%d reason=duplicate_hostile_binding",
ErrRevisitSceneMismatch,
restored.Coordinate,
reference.Kind,
reference.Index,
objectKey,
previous,
)
}
bindings[reference] = objectKey
}
for _, reference := range restored.ExpectedHostiles {
if _, bound := bindings[reference]; !bound {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s kind=%s index=%d reason=expected_hostile_unbound",
ErrRevisitSceneMismatch,
restored.Coordinate,
reference.Kind,
reference.Index,
)
}
}
defeated := make(map[uint32]struct{}, len(restored.DefeatedObjects))
for _, objectKey := range restored.DefeatedObjects {
if _, bound := restored.RuntimeObjects[objectKey]; !bound {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s object_key=%d reason=defeated_object_unbound",
ErrRevisitSceneMismatch,
restored.Coordinate,
objectKey,
)
}
if _, duplicate := defeated[objectKey]; duplicate {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s object_key=%d reason=duplicate_defeated_object",
ErrRevisitSceneMismatch,
restored.Coordinate,
objectKey,
)
}
defeated[objectKey] = struct{}{}
}
computedCleared := len(restored.BlockingHostiles) == 0
if len(restored.BlockingHostiles) != 0 {
computedCleared = true
for _, reference := range restored.BlockingHostiles {
objectKey, bound := bindings[reference]
if !bound {
computedCleared = false
break
}
if _, dead := defeated[objectKey]; !dead {
computedCleared = false
break
}
}
}
if restored.Cleared != computedCleared || restored.Cleared != runCleared {
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
"%w: room=%s cached_cleared=%t computed_cleared=%t run_cleared=%t reason=clear_state_conflict",
ErrRevisitSceneMismatch,
restored.Coordinate,
restored.Cleared,
computedCleared,
runCleared,
)
}
return restored, bindings, defeated, nil
}
func sameDungeonRoomPVFScene(expected, cached DungeonRoomScene) bool {
expected = cloneDungeonRoomScene(expected)
cached = cloneDungeonRoomScene(cached)
expected.RuntimeObjects = nil
cached.RuntimeObjects = nil
expected.DefeatedObjects = nil
cached.DefeatedObjects = nil
expected.Cleared = false
cached.Cleared = false
return reflect.DeepEqual(expected, cached)
}
func dungeonRoomScene(room DungeonRoom) (DungeonRoomScene, error) {
if room.Map == nil {
return DungeonRoomScene{}, ErrRoomMapUnresolved
}
mapValue := cloneMap(room.Map.Map)
scene := DungeonRoomScene{
Coordinate: room.Coordinate,
Start: room.Start,
Boss: room.Boss,
Map: ResolvedMap{Map: mapValue, Source: room.Map.Source, SpecificationType: room.Map.SpecificationType},
Monsters: append([]MonsterSpawn(nil), mapValue.Monsters...),
NPCs: cloneNPCSpawns(mapValue.NPCs),
AICharacters: cloneAICharacters(mapValue.AICharacters),
PassiveObjects: append([]PassiveObject(nil), mapValue.PassiveObjects...),
SpecialPassiveObjects: cloneSpecialPassiveObjects(mapValue.SpecialPassiveObjects),
Portals: clonePortals(mapValue.Portals),
Summons: cloneMapSummons(mapValue.Summons),
RuntimeObjects: make(map[uint32]HostileReference),
}
for index := range scene.Monsters {
reference := HostileReference{Kind: HostileMonster, Index: index}
scene.ExpectedHostiles = append(scene.ExpectedHostiles, reference)
if monsterSpawnBlocksRoom(mapValue, index) {
scene.BlockingHostiles = append(scene.BlockingHostiles, reference)
}
}
for index, actor := range scene.AICharacters {
if strings.EqualFold(strings.Trim(actor.Faction, "[] \t"), "monster") {
scene.ExpectedHostiles = append(scene.ExpectedHostiles, HostileReference{Kind: HostileAICharacter, Index: index})
}
}
if len(scene.BlockingHostiles) == 0 &&
len(mapValue.BloodPhases) == 0 && len(mapValue.UltimatePhases) == 0 {
scene.Cleared = true
}
return scene, nil
}
// A map's [monster team] row is positional to [monster]. Team 0 is the
// player/friendly story side and must not keep room doors or tutorial
// settlement blocked. Missing entries retain the historical hostile default.
func monsterSpawnBlocksRoom(mapValue Map, monsterIndex int) bool {
if monsterIndex < 0 || monsterIndex >= len(mapValue.MonsterTeam) {
return true
}
return mapValue.MonsterTeam[monsterIndex] != 0
}
func containsHostileReference(values []HostileReference, want HostileReference) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func cloneDungeonRoomScene(in DungeonRoomScene) DungeonRoomScene {
in.Map.Map = cloneMap(in.Map.Map)
in.Monsters = append([]MonsterSpawn(nil), in.Monsters...)
in.NPCs = cloneNPCSpawns(in.NPCs)
in.AICharacters = cloneAICharacters(in.AICharacters)
in.PassiveObjects = append([]PassiveObject(nil), in.PassiveObjects...)
in.SpecialPassiveObjects = cloneSpecialPassiveObjects(in.SpecialPassiveObjects)
in.Portals = clonePortals(in.Portals)
in.Summons = cloneMapSummons(in.Summons)
in.ExpectedHostiles = append([]HostileReference(nil), in.ExpectedHostiles...)
in.BlockingHostiles = append([]HostileReference(nil), in.BlockingHostiles...)
in.DefeatedObjects = append([]uint32(nil), in.DefeatedObjects...)
runtimeObjects := in.RuntimeObjects
in.RuntimeObjects = make(map[uint32]HostileReference, len(runtimeObjects))
for key, value := range runtimeObjects {
in.RuntimeObjects[key] = value
}
return in
}
func cloneNPCSpawns(in []NPCSpawn) []NPCSpawn {
out := append([]NPCSpawn(nil), in...)
for index := range out {
out[index].Params = append([]int64(nil), out[index].Params...)
}
return out
}
func cloneAICharacters(in []AICharacter) []AICharacter {
out := append([]AICharacter(nil), in...)
for index := range out {
out[index].Params = append([]int64(nil), out[index].Params...)
}
return out
}
func cloneSpecialPassiveObjects(in []SpecialPassiveObject) []SpecialPassiveObject {
out := append([]SpecialPassiveObject(nil), in...)
for index := range out {
out[index].Spawns = append([]SpecialObjectSpawn(nil), out[index].Spawns...)
out[index].HellParty = append([]HellPartyEntry(nil), out[index].HellParty...)
}
return out
}
func clonePortals(in []Portal) []Portal {
out := append([]Portal(nil), in...)
for index := range out {
if out[index].Position != nil {
position := *out[index].Position
out[index].Position = &position
}
}
return out
}
func cloneMapSummons(in []MapSummon) []MapSummon {
out := append([]MapSummon(nil), in...)
for index := range out {
out[index].Position = append([]int64(nil), out[index].Position...)
out[index].Info = cloneExtensions(out[index].Info)
out[index].SourceSections = cloneSections(out[index].SourceSections)
out[index].UnknownSections = cloneSections(out[index].UnknownSections)
out[index].Extensions = cloneExtensions(out[index].Extensions)
}
return out
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,532 @@
package worldmap
import (
"errors"
"fmt"
"sort"
"strings"
"unicode"
)
var (
ErrTopologyResolverRequired = errors.New("dnf dungeon topology resolver is required")
ErrTopologyEmpty = errors.New("dnf dungeon topology has no rooms")
)
type RoomDirection string
const (
RoomDirectionUp RoomDirection = "up"
RoomDirectionRight RoomDirection = "right"
RoomDirectionDown RoomDirection = "down"
RoomDirectionLeft RoomDirection = "left"
)
type RoomCoordinate struct {
X int64 `json:"x"`
Y int64 `json:"y"`
}
func (c RoomCoordinate) String() string {
return fmt.Sprintf("%d:%d", c.X, c.Y)
}
type RoomNeighbor struct {
Direction RoomDirection `json:"direction"`
Coordinate RoomCoordinate `json:"coordinate"`
}
type DungeonRoom struct {
Coordinate RoomCoordinate `json:"coordinate"`
Start bool `json:"start,omitempty"`
Boss bool `json:"boss,omitempty"`
Map *ResolvedMap `json:"map,omitempty"`
ResolutionError string `json:"resolution_error,omitempty"`
Neighbors []RoomNeighbor `json:"neighbors,omitempty"`
}
type TopologyDiagnostic struct {
Code string `json:"code"`
Coordinate *RoomCoordinate `json:"coordinate,omitempty"`
Message string `json:"message"`
}
type DungeonTopology struct {
DungeonID int64 `json:"dungeon_id"`
MazeIndex int `json:"maze_index"`
Width OptionalInt `json:"width"`
Height OptionalInt `json:"height"`
Start *RoomCoordinate `json:"start,omitempty"`
Bosses []RoomCoordinate `json:"bosses,omitempty"`
AllowMoveBeforeClear bool `json:"allow_move_before_clear,omitempty"`
Diagnostics []TopologyDiagnostic `json:"diagnostics,omitempty"`
rooms map[coordinateKey]DungeonRoom
resolutionErrors map[coordinateKey]error
warpTransitions map[topologyTransition]struct{}
resolver *Resolver
}
type topologyTransition struct {
from coordinateKey
to coordinateKey
}
type topologyDelta struct {
direction RoomDirection
dx int64
dy int64
mask uint8
opposite uint8
}
var topologyDeltas = [...]topologyDelta{
{direction: RoomDirectionUp, dx: 0, dy: -1, mask: 0x02, opposite: 0x08},
{direction: RoomDirectionRight, dx: 1, dy: 0, mask: 0x01, opposite: 0x04},
{direction: RoomDirectionDown, dx: 0, dy: 1, mask: 0x08, opposite: 0x02},
{direction: RoomDirectionLeft, dx: -1, dy: 0, mask: 0x04, opposite: 0x01},
}
func BuildDungeonTopology(resolver *Resolver, dungeonID int64, mazeIndex int) (*DungeonTopology, error) {
if resolver == nil || resolver.table == nil {
return nil, ErrTopologyResolverRequired
}
dungeonIndex, ok := resolver.byDungeon[dungeonID]
if !ok {
return nil, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
}
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
}
dungeon := resolver.table.dungeons[dungeonIndex.dungeonPos]
maze := dungeon.Mazes[mazeIndex]
topology := &DungeonTopology{
DungeonID: dungeonID, MazeIndex: mazeIndex,
Width: maze.Width, Height: maze.Height,
AllowMoveBeforeClear: dungeon.Metadata.Flags["move map even enemy"],
rooms: make(map[coordinateKey]DungeonRoom),
resolutionErrors: make(map[coordinateKey]error),
warpTransitions: make(map[topologyTransition]struct{}),
resolver: resolver,
}
cells := make(map[coordinateKey]struct{})
greedMasks, structuredGreed := addGreedCells(topology, maze, cells)
addSpecificationCells(cells, maze.MapSpecifications)
addSpecificationCells(cells, maze.BossSpecifications)
addSpecificationCells(cells, maze.LayeredSpecifications)
if maze.Start != nil {
start := RoomCoordinate{X: maze.Start.X, Y: maze.Start.Y}
topology.Start = &start
addExplicitCell(topology, maze, cells, start, "start_out_of_bounds")
}
for _, boss := range mazeBossCoordinates(maze.Boss) {
topology.Bosses = append(topology.Bosses, boss)
addExplicitCell(topology, maze, cells, boss, "boss_out_of_bounds")
}
warpConditions := maze.WarpMapConditions
if len(warpConditions) == 0 {
// Preserve programmatic/legacy tables that predate per-maze ownership.
warpConditions = dungeon.WarpMapConditions
}
// A boss-entrance warp does not authorize travel to its destination
// coordinate. Its source remains the logical room while the destination only
// supplies a replacement map template after the condition is complete.
if maze.BossRoomEntranceCondition == nil {
addWarpTransitions(topology, maze, cells, warpConditions)
}
if !structuredGreed {
// The resolver already limits these paths to directories owned by this maze's
// explicit specifications. Coordinates are data, not a map-selection fallback.
for key := range dungeonIndex.mazes[mazeIndex].byCoordinate {
coordinate := RoomCoordinate{X: key.x, Y: key.y}
if withinMazeBounds(maze, coordinate) {
cells[key] = struct{}{}
}
}
}
if len(cells) == 0 {
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrTopologyEmpty, dungeonID, mazeIndex)
}
bosses := make(map[coordinateKey]struct{}, len(topology.Bosses))
for _, boss := range topology.Bosses {
bosses[coordinateKey{x: boss.X, y: boss.Y}] = struct{}{}
}
for key := range cells {
coordinate := RoomCoordinate{X: key.x, Y: key.y}
room := DungeonRoom{Coordinate: coordinate}
room.Start = topology.Start != nil && topology.Start.X == key.x && topology.Start.Y == key.y
_, room.Boss = bosses[key]
resolved, err := resolver.Resolve(ResolveRequest{
DungeonID: dungeonID, MazeIndex: mazeIndex, X: key.x, Y: key.y,
})
if err != nil {
room.ResolutionError = err.Error()
topology.resolutionErrors[key] = err
topology.addDiagnostic("room_map_unresolved", &coordinate, err.Error())
} else {
room.Map = &resolved
}
topology.rooms[key] = room
}
for key, room := range topology.rooms {
for _, delta := range topologyDeltas {
neighborKey := coordinateKey{x: key.x + delta.dx, y: key.y + delta.dy}
if _, ok := topology.rooms[neighborKey]; !ok {
continue
}
if structuredGreed {
currentMask, currentInGrid := greedMasks[key]
neighborMask, neighborInGrid := greedMasks[neighborKey]
if !currentInGrid || !neighborInGrid || currentMask&delta.mask == 0 || neighborMask&delta.opposite == 0 {
continue
}
}
room.Neighbors = append(room.Neighbors, RoomNeighbor{
Direction: delta.direction,
Coordinate: RoomCoordinate{X: neighborKey.x, Y: neighborKey.y},
})
}
topology.rooms[key] = room
}
return topology, nil
}
func (t *DungeonTopology) allowsWarpTransition(from, to coordinateKey) bool {
if t == nil {
return false
}
_, ok := t.warpTransitions[topologyTransition{from: from, to: to}]
return ok
}
func (t *DungeonTopology) Room(coordinate RoomCoordinate) (DungeonRoom, bool) {
if t == nil {
return DungeonRoom{}, false
}
room, ok := t.rooms[coordinateKey{x: coordinate.X, y: coordinate.Y}]
if !ok {
return DungeonRoom{}, false
}
return cloneDungeonRoom(room), true
}
// ReplaceRoomMap updates the resolved map for an existing coordinate while
// preserving topology identity and adjacency.
func (t *DungeonTopology) ReplaceRoomMap(coordinate RoomCoordinate, resolved ResolvedMap) error {
if t == nil {
return ErrTopologyEmpty
}
key := coordinateKey{x: coordinate.X, y: coordinate.Y}
room, ok := t.rooms[key]
if !ok {
return fmt.Errorf("%w: room=%s", ErrMapUnresolved, coordinate)
}
if resolved.Map.ID <= 0 {
return fmt.Errorf("%w: room=%s map=%d", ErrMapReferenceMissing, coordinate, resolved.Map.ID)
}
copy := resolved
copy.Map = cloneMap(resolved.Map)
room.Map = &copy
room.ResolutionError = ""
t.rooms[key] = room
delete(t.resolutionErrors, key)
filtered := t.Diagnostics[:0]
for _, diagnostic := range t.Diagnostics {
if diagnostic.Code == "room_map_unresolved" && diagnostic.Coordinate != nil && *diagnostic.Coordinate == coordinate {
continue
}
filtered = append(filtered, diagnostic)
}
t.Diagnostics = filtered
return nil
}
func (t *DungeonTopology) Rooms() []DungeonRoom {
if t == nil {
return nil
}
rooms := make([]DungeonRoom, 0, len(t.rooms))
for _, room := range t.rooms {
rooms = append(rooms, cloneDungeonRoom(room))
}
sort.Slice(rooms, func(i, j int) bool {
if rooms[i].Coordinate.Y != rooms[j].Coordinate.Y {
return rooms[i].Coordinate.Y < rooms[j].Coordinate.Y
}
return rooms[i].Coordinate.X < rooms[j].Coordinate.X
})
return rooms
}
func (t *DungeonTopology) prepareRoom(room DungeonRoom) DungeonRoom {
return cloneDungeonRoom(room)
}
func (t *DungeonTopology) UnresolvedRooms() []DungeonRoom {
rooms := t.Rooms()
unresolved := rooms[:0]
for _, room := range rooms {
if room.Map == nil {
unresolved = append(unresolved, room)
}
}
return unresolved
}
func (t *DungeonTopology) addDiagnostic(code string, coordinate *RoomCoordinate, message string) {
var copied *RoomCoordinate
if coordinate != nil {
value := *coordinate
copied = &value
}
t.Diagnostics = append(t.Diagnostics, TopologyDiagnostic{Code: code, Coordinate: copied, Message: message})
}
func addGreedCells(
topology *DungeonTopology,
maze Maze,
cells map[coordinateKey]struct{},
) (map[coordinateKey]uint8, bool) {
if maze.Greed == "" {
return nil, false
}
if !maze.Width.Set || !maze.Height.Set || maze.Width.Value <= 0 || maze.Height.Value <= 0 {
topology.addDiagnostic("maze_size_invalid", nil, "[greed] requires positive [size] width and height")
return nil, false
}
rows := normalizedGreedRows(maze.Greed)
if len(rows) == 0 {
return nil, false
}
paired := greedRowsArePaired(rows, maze.Width.Value)
if int64(len(rows)) < maze.Height.Value {
topology.addDiagnostic(
"maze_grid_rows_short", nil,
fmt.Sprintf("[greed] has %d rows, [size] declares %d; omitted rows are empty", len(rows), maze.Height.Value),
)
} else if int64(len(rows)) > maze.Height.Value {
topology.addDiagnostic(
"maze_grid_rows_extra", nil,
fmt.Sprintf("[greed] has %d rows, [size] declares %d", len(rows), maze.Height.Value),
)
}
masks := make(map[coordinateKey]uint8)
rowCount := int64(len(rows))
if rowCount > maze.Height.Value {
rowCount = maze.Height.Value
}
for y := int64(0); y < rowCount; y++ {
row := rows[y]
unitWidth := int64(1)
if paired {
unitWidth = 2
if len(row)%2 != 0 {
topology.addDiagnostic(
"maze_grid_row_odd", &RoomCoordinate{X: int64(len(row) / 2), Y: y},
fmt.Sprintf("[greed] row %d has an unmatched paired symbol", y),
)
}
}
logicalWidth := int64(len(row)) / unitWidth
if logicalWidth < maze.Width.Value {
topology.addDiagnostic(
"maze_grid_row_short", &RoomCoordinate{X: logicalWidth, Y: y},
fmt.Sprintf("[greed] row %d has %d cells, [size] declares %d; omitted cells are empty", y, logicalWidth, maze.Width.Value),
)
} else if logicalWidth > maze.Width.Value {
topology.addDiagnostic(
"maze_grid_row_extra", &RoomCoordinate{X: maze.Width.Value, Y: y},
fmt.Sprintf("[greed] row %d has %d cells, [size] declares %d", y, logicalWidth, maze.Width.Value),
)
logicalWidth = maze.Width.Value
}
for x := int64(0); x < logicalWidth; x++ {
valueIndex := x * unitWidth
if paired {
if valueIndex+1 >= int64(len(row)) {
break
}
if row[valueIndex] != row[valueIndex+1] {
coordinate := RoomCoordinate{X: x, Y: y}
topology.addDiagnostic(
"maze_grid_pair_mismatch", &coordinate,
fmt.Sprintf("[greed] pair %q%q does not repeat one room code", row[valueIndex], row[valueIndex+1]),
)
continue
}
}
mask, ok := greedRoomMask(row[valueIndex])
if !ok {
coordinate := RoomCoordinate{X: x, Y: y}
topology.addDiagnostic(
"maze_grid_code_unknown", &coordinate,
fmt.Sprintf("[greed] room code %q is unknown", row[valueIndex]),
)
continue
}
if mask != 0 {
key := coordinateKey{x: x, y: y}
cells[key] = struct{}{}
masks[key] = mask
}
}
}
return masks, true
}
func normalizedGreedRows(value string) [][]rune {
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
rows := make([][]rune, 0, len(lines))
for _, line := range lines {
row := make([]rune, 0, len(line))
for _, symbol := range line {
if unicode.IsSpace(symbol) || symbol == '`' || symbol == ',' {
continue
}
row = append(row, symbol)
}
rows = append(rows, row)
}
for len(rows) > 0 && len(rows[0]) == 0 {
rows = rows[1:]
}
for len(rows) > 0 && len(rows[len(rows)-1]) == 0 {
rows = rows[:len(rows)-1]
}
return rows
}
func greedRowsArePaired(rows [][]rune, width int64) bool {
allRepeated := true
hasSymbols := false
for _, row := range rows {
if int64(len(row)) > width {
return true
}
if len(row) == 0 {
continue
}
hasSymbols = true
if len(row)%2 != 0 {
allRepeated = false
continue
}
for index := 0; index < len(row); index += 2 {
if row[index] != row[index+1] {
allRepeated = false
break
}
}
}
return hasSymbols && allRepeated
}
func greedRoomMask(value rune) (uint8, bool) {
if value >= 'a' && value <= 'p' {
value -= 'a' - 'A'
}
if value >= 'A' && value <= 'P' {
return uint8(value - 'A'), true
}
switch value {
case '0', '.', 'x', 'X':
return 0, true
case '1':
return 0x0f, true
default:
return 0, false
}
}
func addSpecificationCells(cells map[coordinateKey]struct{}, specifications []MapSpecification) {
for _, specification := range specifications {
cells[coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}] = struct{}{}
}
}
func addWarpTransitions(
topology *DungeonTopology,
maze Maze,
cells map[coordinateKey]struct{},
conditions []WarpMapCondition,
) {
for _, condition := range conditions {
source := RoomCoordinate{X: condition.Source.X, Y: condition.Source.Y}
destination := RoomCoordinate{X: condition.Destination.X, Y: condition.Destination.Y}
// Negative source coordinates are PVF mechanism sentinels. They are not
// client-requestable maze rooms and must never become travel origins.
if source.X < 0 || source.Y < 0 {
continue
}
if !withinMazeBounds(maze, source) {
topology.addDiagnostic(
"warp_source_out_of_bounds", &source,
fmt.Sprintf("warp source %s is outside declared maze size", source),
)
continue
}
if destination.X < 0 || destination.Y < 0 || !withinMazeBounds(maze, destination) {
topology.addDiagnostic(
"warp_destination_out_of_bounds", &destination,
fmt.Sprintf("warp destination %s is outside declared maze size", destination),
)
continue
}
sourceKey := coordinateKey{x: source.X, y: source.Y}
destinationKey := coordinateKey{x: destination.X, y: destination.Y}
cells[sourceKey] = struct{}{}
cells[destinationKey] = struct{}{}
topology.warpTransitions[topologyTransition{from: sourceKey, to: destinationKey}] = struct{}{}
}
}
func addExplicitCell(
topology *DungeonTopology,
maze Maze,
cells map[coordinateKey]struct{},
coordinate RoomCoordinate,
diagnosticCode string,
) {
if !withinMazeBounds(maze, coordinate) {
topology.addDiagnostic(
diagnosticCode, &coordinate,
fmt.Sprintf("coordinate %s is outside declared maze size", coordinate),
)
}
cells[coordinateKey{x: coordinate.X, y: coordinate.Y}] = struct{}{}
}
func withinMazeBounds(maze Maze, coordinate RoomCoordinate) bool {
if maze.Width.Set && maze.Width.Value > 0 && (coordinate.X < 0 || coordinate.X >= maze.Width.Value) {
return false
}
if maze.Height.Set && maze.Height.Value > 0 && (coordinate.Y < 0 || coordinate.Y >= maze.Height.Value) {
return false
}
return true
}
func mazeBossCoordinates(point *MazePoint) []RoomCoordinate {
if point == nil {
return nil
}
coordinates := []RoomCoordinate{{X: point.X, Y: point.Y}}
for index := 0; index+1 < len(point.Params); index += 2 {
coordinates = append(coordinates, RoomCoordinate{X: point.Params[index], Y: point.Params[index+1]})
}
return coordinates
}
func cloneDungeonRoom(room DungeonRoom) DungeonRoom {
room.Neighbors = append([]RoomNeighbor(nil), room.Neighbors...)
if room.Map != nil {
resolved := *room.Map
resolved.Map = cloneMap(resolved.Map)
room.Map = &resolved
}
return room
}
@@ -0,0 +1,416 @@
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
}
@@ -0,0 +1,254 @@
package worldmap
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
type SectionValueKind string
const (
SectionFlag SectionValueKind = "flag"
SectionIntegers SectionValueKind = "integers"
SectionNumbers SectionValueKind = "numbers"
SectionTexts SectionValueKind = "texts"
SectionMixed SectionValueKind = "mixed"
)
type ExtensionSection struct {
Name string `json:"name"`
Occurrence int `json:"occurrence"`
Scope []string `json:"scope,omitempty"`
Block bool `json:"block,omitempty"`
Kind SectionValueKind `json:"kind"`
Integers []int64 `json:"integers,omitempty"`
Numbers []float64 `json:"numbers,omitempty"`
Texts []string `json:"texts,omitempty"`
Symbols []string `json:"symbols,omitempty"`
Tokens []dnfpvf.Token `json:"tokens,omitempty"`
}
type OptionalNumber struct {
Value float64 `json:"value"`
Set bool `json:"set"`
}
// BloodAltarDungeonKind is the value carried by PVF [blood dungeon].
type BloodAltarDungeonKind uint8
const (
BloodAltarDungeonKindEndless BloodAltarDungeonKind = 1
BloodAltarDungeonKindUltimate BloodAltarDungeonKind = 2
)
// LostLandParameters is the pair of PVF millisecond phase durations consumed
// by the current client's Forgotten Land day/night UI.
type LostLandParameters struct {
DayMilliseconds int64 `json:"day_milliseconds"`
NightMilliseconds int64 `json:"night_milliseconds"`
Set bool `json:"set"`
}
// DungeonLimitTime preserves both PVF [limit time] values. The current
// Forgotten Land client reads PrimarySeconds as its countdown duration; the
// second value is retained without assigning an unsupported semantic name.
type DungeonLimitTime struct {
PrimarySeconds int64 `json:"primary_seconds"`
SecondarySeconds int64 `json:"secondary_seconds"`
Set bool `json:"set"`
}
// DungeonEventMonsterDefinition preserves one dungeon-wide PVF
// [event monster], [event monster2], or [event monster3] row. The integer
// layout is client-owned and varies between generations, so Values remains an
// ordered typed vector rather than assigning unverified field names.
type DungeonEventMonsterDefinition struct {
Kind string `json:"kind"`
Values []int64 `json:"values"`
}
type DungeonMetadata struct {
Name string `json:"name,omitempty"`
Explain string `json:"explain,omitempty"`
CutsceneImage Resource `json:"cutscene_image"`
MinimapImage string `json:"minimap_image,omitempty"`
EnteringTitle string `json:"entering_title,omitempty"`
MinimumRequiredLevel OptionalInt `json:"minimum_required_level"`
BasisLevel OptionalInt `json:"basis_level"`
ExperienceIncreasingPoint OptionalNumber `json:"experience_increasing_point"`
BackgroundPosition OptionalInt `json:"background_position"`
DungeonType string `json:"dungeon_type,omitempty"`
Champion []int64 `json:"champion,omitempty"`
PathgateObjects []int64 `json:"pathgate_objects,omitempty"`
WorldMapPatternInfo []RawSection `json:"worldmap_pattern_info,omitempty"`
WorldMapInfo []RawSection `json:"worldmap_info,omitempty"`
Difficulty OptionalInt `json:"difficulty"`
DifficultyLevels []int64 `json:"difficulty_levels,omitempty"`
DesignatedDifficulties []int64 `json:"designated_difficulties,omitempty"`
TutorialDungeon OptionalInt `json:"tutorial_dungeon"`
// CoinLimit is [coin limit]: how many revive coins one entry of this
// dungeon may consume (Luke's three sanctums declare 8).
// 上游 bca733b/8b465b5 这条线加的字段;卢克一阶段「每圣所单次进图 8 次」用它。
CoinLimit OptionalInt `json:"coin_limit"`
NoFatigue bool `json:"no_fatigue,omitempty"`
NamedMonsters []int64 `json:"named_monsters,omitempty"`
RecommendedLevels []int64 `json:"recommended_levels,omitempty"`
LimitPartyCount OptionalInt `json:"limit_party_count"`
LostLandParameters LostLandParameters `json:"lost_land_parameters"`
LimitTime DungeonLimitTime `json:"limit_time"`
IntegerValues map[string]int64 `json:"integer_values,omitempty"`
NumberValues map[string]float64 `json:"number_values,omitempty"`
Flags map[string]bool `json:"flags,omitempty"`
TextValues map[string][]string `json:"text_values,omitempty"`
TournamentRoundFatigue int64 `json:"tournament_round_fatigue"`
TournamentGoldRate int64 `json:"tournament_gold_rate"`
TournamentClearRewardExp []TournamentRoundExperience `json:"tournament_clear_reward_exp,omitempty"`
TournamentResultCards []TournamentResultCard `json:"tournament_result_cards,omitempty"`
TournamentRewardItemRates []TournamentRewardItemRate `json:"tournament_reward_item_rates,omitempty"`
RequiredItems []TicketItem `json:"required_items,omitempty"`
// EventRequiredItems and AddedRequiredItems remain separate evidence. The
// active selectable dungeon list does not route either section, and the
// current op16 request has no proven event/schedule selector. They must not
// be silently conjoined with the ordinary [required item] contract.
EventRequiredItems []TicketItem `json:"event_required_items,omitempty"`
AddedRequiredItems []TicketItem `json:"added_required_items,omitempty"`
// SpecialPassiveObjectItems is dungeon-wide in the current PVF. Every maze
// route consumes the same reward groups; placing the section on the last
// parsed maze makes earlier routes silently lose their configured drops.
SpecialPassiveObjectItems []SpecialPassiveObjectItem `json:"special_passive_object_items,omitempty"`
// InfernoDungeon and InfernoQuestIDs are the current PVF contract for
// Time Rift. They are intentionally separate from ordinary hell-party
// region metadata: the dungeon itself owns the route table and opening
// quest.
InfernoDungeon bool `json:"inferno_dungeon,omitempty"`
InfernoQuestIDs []int64 `json:"inferno_quest_ids,omitempty"`
SelectDungeonNormalDisabled bool `json:"select_dungeon_normal_disabled,omitempty"`
HellPartyRequiredItemCount OptionalInt `json:"hell_party_required_item_count"`
EventMonsterRandomMap OptionalInt `json:"event_monster_random_map"`
EventMonsters []DungeonEventMonsterDefinition `json:"event_monsters,omitempty"`
}
type MazePoint struct {
X int64 `json:"x"`
Y int64 `json:"y"`
Params []int64 `json:"params,omitempty"`
}
type MapSpecification struct {
Type string `json:"type"`
Coordinate Point `json:"coordinate"`
MapIDs []int64 `json:"map_ids"`
SectionOccurrence int `json:"section_occurrence"`
}
type RidableObject struct {
MazeCoordinate *Point `json:"maze_coordinate,omitempty"`
ObjectIndex OptionalInt `json:"object_index"`
Team string `json:"team,omitempty"`
Position *Point `json:"position,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
}
type RandomizedObjectScript struct {
SelectCount OptionalInt `json:"select_count"`
Regenerate OptionalInt `json:"regenerate"`
MinimapIcon OptionalInt `json:"minimap_icon"`
Objects []RidableObject `json:"objects,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
}
type ClearCondition struct {
Type string `json:"type"`
TargetID int64 `json:"target_id"`
Count int64 `json:"count"`
}
// BossRoomEntranceHuntMonster is one PVF [hunt monster] target from a
// per-maze [boss room entrance condition]. Opaque preserves the middle value
// of the current three-integer row without assigning an unverified meaning.
type BossRoomEntranceHuntMonster struct {
TargetID int64 `json:"target_id"`
Opaque int64 `json:"opaque"`
Count int64 `json:"count"`
}
// BossRoomEntranceCondition is the typed per-maze gate that precedes a boss
// room. The current PVF encodes its action and condition labels as bracketed
// text tokens inside one section rather than as nested sections.
type BossRoomEntranceCondition struct {
Action string `json:"action"`
TimeCondition []int64 `json:"time_condition"`
HuntMonsters []BossRoomEntranceHuntMonster `json:"hunt_monsters"`
}
// WarpMapCondition is one PVF [warp map condition] source/destination pair.
// The source is a logical trigger coordinate. It is intentionally not treated
// as an ordinary maze map assignment: several conditions may share one source
// and the active destination is selected by the dungeon mechanism at runtime.
type WarpMapCondition struct {
Source Point `json:"source"`
Destination Point `json:"destination"`
}
// SpecialPassiveObjectItem is one weighted candidate of a dgn
// [special passive object item] group. The section is a list of records
// `<group index> <level override> <item count> (<item id> <drop rate>)...`;
// a map [special passive object] child of kind [item] selects the record by
// its spawn code, so Index is the record's position in that vector and not a
// passive-object template id. DropRate is a 0..10000 slice of one roll: a
// group's rates never renormalize, so a group summing below 10000 can roll
// "no drop", and one roll yields at most one item.
type SpecialPassiveObjectItem struct {
Index int64 `json:"index"`
LevelOverride int64 `json:"level_override"`
ItemID int64 `json:"item_id"`
DropRate int64 `json:"drop_rate"`
}
type Maze struct {
Index int `json:"index"`
ChanceRate OptionalInt `json:"chance_rate"`
InfernoSpecial bool `json:"inferno_special,omitempty"`
EscapeHell OptionalInt `json:"escape_hell"`
Width OptionalInt `json:"width"`
Height OptionalInt `json:"height"`
Greed string `json:"greed,omitempty"`
MapSpecifications []MapSpecification `json:"map_specifications,omitempty"`
Start *MazePoint `json:"start,omitempty"`
Boss *MazePoint `json:"boss,omitempty"`
HitCount []int64 `json:"hit_count,omitempty"`
SealDoorAppearRate OptionalInt `json:"seal_door_appear_rate"`
SealDoorMapIndex OptionalInt `json:"seal_door_map_index"`
SealDoorPosition []int64 `json:"seal_door_position,omitempty"`
QuestConnection []int64 `json:"quest_connection,omitempty"`
SpecialPassiveObjectItems []SpecialPassiveObjectItem `json:"special_passive_object_items,omitempty"`
BossMapSpecifications []RawSection `json:"boss_map_specifications,omitempty"`
LayeredMapSpecifications []RawSection `json:"layered_map_specifications,omitempty"`
BossSpecifications []MapSpecification `json:"boss_specifications,omitempty"`
LayeredSpecifications []MapSpecification `json:"layered_specifications,omitempty"`
RandomizedObjects []RandomizedObjectScript `json:"randomized_objects,omitempty"`
ClearConditions []ClearCondition `json:"clear_conditions,omitempty"`
BossRoomEntranceCondition *BossRoomEntranceCondition `json:"boss_room_entrance_condition,omitempty"`
WarpMapConditions []WarpMapCondition `json:"warp_map_conditions,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
OpaqueSections []RawSection `json:"opaque_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
type Dungeon struct {
ID int64 `json:"id"`
Path string `json:"path"`
Metadata DungeonMetadata `json:"metadata"`
BloodAltarKind BloodAltarDungeonKind `json:"blood_altar_kind,omitempty"`
BloodMaxRounds int64 `json:"blood_max_rounds,omitempty"`
Mazes []Maze `json:"mazes,omitempty"`
WarpMapConditions []WarpMapCondition `json:"warp_map_conditions,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
OpaqueSections []RawSection `json:"opaque_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
@@ -0,0 +1,42 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFDungeon7542EscapeHellBelongsToEachMaze(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify dungeon 7542 escape-hell maze ownership")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipMaps: true})
if err != nil {
t.Fatal(err)
}
dungeon, found := table.FindDungeon(7542)
if !found {
t.Fatal("escape-hell dungeon 7542 is missing")
}
if got := dungeon.Metadata.IntegerValues["hell dungeon"]; got != 1 {
t.Fatalf("dungeon 7542 hell-dungeon marker=%d want 1", got)
}
if _, leaked := dungeon.Metadata.IntegerValues["escape hell"]; leaked {
t.Fatalf("maze escape-hell leaked into dungeon metadata: %+v", dungeon.Metadata.IntegerValues)
}
if len(dungeon.Mazes) != 6 {
t.Fatalf("dungeon 7542 mazes=%d want 6", len(dungeon.Mazes))
}
for mazeIndex, maze := range dungeon.Mazes {
if !maze.EscapeHell.Set || maze.EscapeHell.Value != 1 {
t.Fatalf("dungeon 7542 maze=%d escape-hell=%+v want 1", mazeIndex, maze.EscapeHell)
}
}
}
@@ -0,0 +1,60 @@
package worldmap
import (
"os"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFForgottenLandDefinition(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
const dungeonPath = "dungeon/Fun_test/Fun_test2.dgn"
source, err := archive.ReadText(dungeonPath)
if err != nil {
t.Fatal(err)
}
document, err := dnfpvf.Parse(dungeonPath, source)
if err != nil {
t.Fatal(err)
}
dungeon := ParseDungeon(4000, dungeonPath, document)
if dungeon.Metadata.DungeonType != "lost land" {
t.Fatalf("dungeon type=%q", dungeon.Metadata.DungeonType)
}
if got := dungeon.Metadata.LostLandParameters; !got.Set || got.DayMilliseconds != 120000 || got.NightMilliseconds != 120000 {
t.Fatalf("lost land parameters=%+v", got)
}
if got := dungeon.Metadata.LimitTime; !got.Set || got.PrimarySeconds != 600 || got.SecondarySeconds != 1200 {
for _, section := range dungeon.SourceSections {
if strings.Contains(sectionKey(section.Name), "limit") || strings.Contains(sectionKey(section.Name), "lost land") {
t.Logf("section name=%q scope=%q block=%t tokens=%+v", section.Name, section.Scope, section.Block, section.Tokens)
}
}
t.Fatalf("limit time=%+v", got)
}
if len(dungeon.WarpMapConditions) != 50 {
t.Fatalf("warp transition count=%d", len(dungeon.WarpMapConditions))
}
wantSource := Point{X: 8, Y: 7}
wantDestination := Point{X: 0, Y: 9}
found := false
for _, condition := range dungeon.WarpMapConditions {
if condition.Source == wantSource && condition.Destination == wantDestination {
found = true
break
}
}
if !found {
t.Fatalf("missing real Forgotten Land warp %v -> %v", wantSource, wantDestination)
}
}
@@ -0,0 +1,307 @@
package worldmap
import (
"context"
"errors"
"fmt"
"path"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var (
ErrIndexRequired = errors.New("dnf worldmap index is required")
ErrListEmpty = errors.New("dnf worldmap list is empty")
ErrDocMissing = errors.New("dnf worldmap document is missing")
)
type Table struct {
maps []Map
areas []Area
dungeons []Dungeon
mapByID map[int64]int
mapByPath map[string]int
areaByID map[int64]int
areaByPath map[string]int
areaByDungeon map[int64]int
dungeonByID map[int64]int
dungeonByPath map[string]int
}
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
if index == nil {
return nil, ErrIndexRequired
}
if ctx == nil {
ctx = context.Background()
}
table := &Table{
mapByID: make(map[int64]int), mapByPath: make(map[string]int),
areaByID: make(map[int64]int), areaByPath: make(map[string]int), areaByDungeon: make(map[int64]int),
dungeonByID: make(map[int64]int), dungeonByPath: make(map[string]int),
}
if !options.SkipMaps {
listPath := options.MapListPath
if listPath == "" {
listPath = DefaultMapList
}
entries := index.List(listPath)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
doc, ok := index.Document(entry.Path)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
}
parsed, err := parseMapWithImports(entry.ID, entry.Path, doc, func(candidate string) (string, *dnfpvf.Document, error) {
importDoc, ok := index.Document(candidate)
if !ok {
return "", nil, fmt.Errorf("%w: %s", ErrDocMissing, candidate)
}
return candidate, importDoc, nil
})
if err != nil {
return nil, fmt.Errorf("load dnf worldmap map %q: %w", entry.Path, err)
}
if options.CompactRuntime {
compactMapForRuntime(&parsed)
}
pos := len(table.maps)
table.maps = append(table.maps, parsed)
if _, exists := table.mapByID[parsed.ID]; !exists {
table.mapByID[parsed.ID] = pos
}
table.mapByPath[pathKey(parsed.Path)] = pos
}
}
if !options.SkipDungeons {
listPath := options.DungeonListPath
if listPath == "" {
listPath = DefaultDungeonList
}
entries := index.List(listPath)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
doc, ok := index.Document(entry.Path)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
}
parsed := ParseDungeon(entry.ID, entry.Path, doc)
if options.CompactRuntime {
compactDungeonForRuntime(&parsed)
}
pos := len(table.dungeons)
table.dungeons = append(table.dungeons, parsed)
if _, exists := table.dungeonByID[parsed.ID]; !exists {
table.dungeonByID[parsed.ID] = pos
}
table.dungeonByPath[pathKey(parsed.Path)] = pos
}
}
if !options.SkipAreas {
listPath := options.WorldMapListPath
if listPath == "" {
listPath = DefaultWorldMapList
}
entries := index.List(listPath)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
doc, ok := index.Document(entry.Path)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
}
parsed := ParseArea(entry.ID, entry.Path, doc)
if options.CompactRuntime {
compactAreaForRuntime(&parsed)
}
pos := len(table.areas)
table.areas = append(table.areas, parsed)
if _, exists := table.areaByID[parsed.ID]; !exists {
table.areaByID[parsed.ID] = pos
}
table.areaByPath[pathKey(parsed.Path)] = pos
for _, dungeon := range parsed.Dungeons {
if dungeon.DungeonID <= 0 {
continue
}
if _, exists := table.areaByDungeon[dungeon.DungeonID]; !exists {
table.areaByDungeon[dungeon.DungeonID] = pos
}
}
}
}
return table, nil
}
func (t *Table) FindDungeon(id int64) (Dungeon, bool) {
if t == nil {
return Dungeon{}, false
}
pos, ok := t.dungeonByID[id]
if !ok {
return Dungeon{}, false
}
return cloneDungeon(t.dungeons[pos]), true
}
func (t *Table) FindDungeonPath(dungeonPath string) (Dungeon, bool) {
if t == nil {
return Dungeon{}, false
}
pos, ok := t.dungeonByPath[pathKey(dungeonPath)]
if !ok {
return Dungeon{}, false
}
return cloneDungeon(t.dungeons[pos]), true
}
func (t *Table) FindMap(id int64) (Map, bool) {
if t == nil {
return Map{}, false
}
pos, ok := t.mapByID[id]
if !ok {
return Map{}, false
}
return cloneMap(t.maps[pos]), true
}
func (t *Table) FindMapPath(mapPath string) (Map, bool) {
if t == nil {
return Map{}, false
}
pos, ok := t.mapByPath[pathKey(mapPath)]
if !ok {
return Map{}, false
}
return cloneMap(t.maps[pos]), true
}
func (t *Table) FindArea(id int64) (Area, bool) {
if t == nil {
return Area{}, false
}
pos, ok := t.areaByID[id]
if !ok {
return Area{}, false
}
return cloneArea(t.areas[pos]), true
}
func (t *Table) FindAreaPath(areaPath string) (Area, bool) {
if t == nil {
return Area{}, false
}
pos, ok := t.areaByPath[pathKey(areaPath)]
if !ok {
return Area{}, false
}
return cloneArea(t.areas[pos]), true
}
func (t *Table) FindAreaByDungeon(dungeonID int64) (Area, bool) {
if t == nil {
return Area{}, false
}
pos, ok := t.areaByDungeon[dungeonID]
if !ok {
return Area{}, false
}
return cloneArea(t.areas[pos]), true
}
// HellPartyAreasForQuest returns the PVF world-map regions whose [hell quest]
// declaration names questID. Only regions explicitly marked as abyss regions
// participate, so a malformed or unrelated map declaration cannot unlock one.
func (t *Table) HellPartyAreasForQuest(questID int64) []Area {
if t == nil || questID <= 0 {
return nil
}
areas := make([]Area, 0, 1)
for _, area := range t.areas {
if !area.HellDungeon.Set || area.HellDungeon.Value != 1 {
continue
}
for _, candidate := range area.HellQuestIDs {
if candidate == questID {
areas = append(areas, cloneArea(area))
break
}
}
}
return areas
}
func (t *Table) Maps() []Map {
if t == nil {
return nil
}
out := make([]Map, len(t.maps))
for i := range t.maps {
out[i] = cloneMap(t.maps[i])
}
return out
}
func (t *Table) Areas() []Area {
if t == nil {
return nil
}
out := make([]Area, len(t.areas))
for i := range t.areas {
out[i] = cloneArea(t.areas[i])
}
return out
}
func (t *Table) Dungeons() []Dungeon {
if t == nil {
return nil
}
out := make([]Dungeon, len(t.dungeons))
for i := range t.dungeons {
out[i] = cloneDungeon(t.dungeons[i])
}
return out
}
func (t *Table) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
mazes := 0
for _, dungeon := range t.dungeons {
mazes += len(dungeon.Mazes)
}
return Snapshot{
Maps: len(t.maps), Areas: len(t.areas), Dungeons: len(t.dungeons),
Mazes: mazes, DungeonRefs: len(t.areaByDungeon),
}
}
func pathKey(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
value = strings.TrimPrefix(value, "./")
value = strings.TrimPrefix(value, "/")
}
if value == "" {
return ""
}
return strings.ToLower(path.Clean(value))
}
@@ -0,0 +1,167 @@
package worldmap
import (
"context"
"errors"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestLoadBuildsReadOnlyMapAndAreaIndexes(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), textSource{
DefaultMapList: "10 `map/town/test.map`\n",
"map/town/test.map": "[map name]\n`Test Town`\n[npc]\n100 `[left]` 1 2 3\n",
DefaultDungeonList: "30 `dungeon/test.dgn`\n",
"dungeon/test.dgn": "[name]\n`Test Dungeon`\n[maze info]\n[size]\n1 1\n[map specification]\n`map` 0 0 10\n[start map]\n0 0\n[boss map]\n0 0\n",
DefaultWorldMapList: "20 `worldmap/test.wdm`\n",
"worldmap/test.wdm": "[name]\n`Test Area`\n[dungeon]\n700 -1\n[/dungeon]\n",
}, dnfpvf.BuildOptions{Lists: []string{DefaultMapList, DefaultDungeonList, DefaultWorldMapList}})
if err != nil {
t.Fatalf("build pvf index: %v", err)
}
table, err := Load(context.Background(), index, Options{})
if err != nil {
t.Fatalf("load worldmap: %v", err)
}
if snapshot := table.Snapshot(); snapshot.Maps != 1 || snapshot.Areas != 1 || snapshot.Dungeons != 1 || snapshot.Mazes != 1 || snapshot.DungeonRefs != 1 {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
parsedMap, ok := table.FindMap(10)
if !ok || parsedMap.Name != "Test Town" || len(parsedMap.NPCs) != 1 {
t.Fatalf("unexpected map lookup: %+v %v", parsedMap, ok)
}
area, ok := table.FindAreaByDungeon(700)
if !ok || area.ID != 20 {
t.Fatalf("unexpected area lookup: %+v %v", area, ok)
}
parsedMap.NPCs[0].Position.X = 999
again, _ := table.FindMapPath("./MAP/TOWN/TEST.MAP")
if again.NPCs[0].Position.X != 1 {
t.Fatalf("table returned mutable map data: %+v", again.NPCs)
}
area.Dungeons[0].DungeonID = 999
againArea, _ := table.FindAreaPath("WORLDMAP/TEST.WDM")
if againArea.Dungeons[0].DungeonID != 700 {
t.Fatalf("table returned mutable area data: %+v", againArea.Dungeons)
}
dungeon, ok := table.FindDungeon(30)
if !ok || dungeon.Metadata.Name != "Test Dungeon" || len(dungeon.Mazes) != 1 {
t.Fatalf("unexpected dungeon lookup: %+v %v", dungeon, ok)
}
dungeon.Mazes[0].MapSpecifications[0].MapIDs[0] = 999
againDungeon, _ := table.FindDungeonPath("./DUNGEON/TEST.DGN")
if againDungeon.Mazes[0].MapSpecifications[0].MapIDs[0] != 10 {
t.Fatalf("table returned mutable dungeon data: %+v", againDungeon.Mazes)
}
}
func TestHellPartyAreasForQuestRequiresExplicitHellArea(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), textSource{
DefaultWorldMapList: "290 `worldmap/metrocenter.wdm`\n291 `worldmap/ordinary.wdm`\n",
"worldmap/metrocenter.wdm": "[name]\n`Metro Center`\n[hell dungeon]\n1\n[hell quest]\n2900\n[/hell quest]\n",
"worldmap/ordinary.wdm": "[name]\n`Ordinary`\n[hell quest]\n2900\n[/hell quest]\n",
}, dnfpvf.BuildOptions{Lists: []string{DefaultWorldMapList}})
if err != nil {
t.Fatal(err)
}
table, err := Load(context.Background(), index, Options{SkipMaps: true, SkipDungeons: true})
if err != nil {
t.Fatal(err)
}
areas := table.HellPartyAreasForQuest(2900)
if len(areas) != 1 || areas[0].ID != 290 {
t.Fatalf("hell areas for quest 2900=%+v", areas)
}
}
func TestLoadBoundaries(t *testing.T) {
if _, err := Load(context.Background(), nil, Options{}); !errors.Is(err, ErrIndexRequired) {
t.Fatalf("expected ErrIndexRequired, got %v", err)
}
index, err := dnfpvf.Build(context.Background(), textSource{
DefaultMapList: "1 `missing.map`\n",
}, dnfpvf.BuildOptions{Paths: []string{DefaultMapList}})
if err != nil {
t.Fatal(err)
}
if _, err := Load(context.Background(), index, Options{SkipAreas: true}); !errors.Is(err, ErrListEmpty) {
t.Fatalf("expected ErrListEmpty, got %v", err)
}
}
func TestLoadSourceUsesLocalPVFMultilineCompatibility(t *testing.T) {
source := textSource{
DefaultDungeonList: "3 `Act1/MirkWood.dgn`\n",
"dungeon/Act1/MirkWood.dgn": "[name]\n`MirkWood`\n[maze info]\n[size]\n2 1\n[greed]\n`AAII\n BBEE`\n[map specification]\n`map` 0 0 10 `boss` 1 0 11\n[start map]\n0 0\n[boss map]\n1 0\n",
}
table, err := LoadSource(context.Background(), source, Options{SkipMaps: true, SkipAreas: true})
if err != nil {
t.Fatalf("load source: %v", err)
}
if snapshot := table.Snapshot(); snapshot.Dungeons != 1 || snapshot.Mazes != 1 {
t.Fatalf("snapshot = %+v", snapshot)
}
dungeon, ok := table.FindDungeon(3)
if !ok || dungeon.Mazes[0].Greed != "AAII\n BBEE" || len(dungeon.Mazes[0].MapSpecifications) != 2 {
t.Fatalf("multiline source dungeon = %+v, %v", dungeon, ok)
}
}
func TestLoadMapImportsExposeSharedNPCSpawns(t *testing.T) {
source := textSource{
DefaultMapList: "10 `town/root.map`\n",
"map/town/root.map": "[import script]\n`Common/Gate.map`\n[npc]\n23 `[left]` 10 20 0\n",
"map/Common/Gate.map": "[npc]\n22 `[right]` 187 200 0\n[passive object]\n99 1 2 0\n",
DefaultDungeonList: "",
DefaultWorldMapList: "",
}
table, err := LoadSource(context.Background(), source, Options{SkipDungeons: true, SkipAreas: true})
if err != nil {
t.Fatalf("load source with map import: %v", err)
}
parsed, ok := table.FindMap(10)
if !ok {
t.Fatal("importing map missing")
}
if len(parsed.NPCs) != 2 || parsed.NPCs[0].NPCID != 22 || parsed.NPCs[0].Position.X != 187 || parsed.NPCs[0].Position.Y != 200 {
t.Fatalf("imported NPCs = %+v", parsed.NPCs)
}
if len(parsed.PassiveObjects) != 1 || parsed.PassiveObjects[0].ObjectID != 99 {
t.Fatalf("imported passive objects = %+v", parsed.PassiveObjects)
}
}
func TestLoadIndexMapImportsExposeSharedNPCSpawns(t *testing.T) {
source := textSource{
DefaultMapList: "10 `town/root.map`\n",
"map/town/root.map": "[import script]\n`Common/Gate.map`\n",
"map/Common/Gate.map": "[npc]\n22 `[right]` 187 200 0\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{
Lists: []string{DefaultMapList},
Paths: []string{"map/town/root.map", "map/Common/Gate.map"},
})
if err != nil {
t.Fatalf("build index: %v", err)
}
table, err := Load(context.Background(), index, Options{SkipDungeons: true, SkipAreas: true})
if err != nil {
t.Fatalf("load index with map import: %v", err)
}
parsed, ok := table.FindMap(10)
if !ok || len(parsed.NPCs) != 1 || parsed.NPCs[0].NPCID != 22 {
t.Fatalf("imported index NPCs = %+v, exists=%v", parsed.NPCs, ok)
}
}
type textSource map[string]string
func (s textSource) ReadText(relativePath string) (string, error) {
for key, value := range s {
if pathKey(key) == pathKey(relativePath) {
return value, nil
}
}
return "", dnfpvf.ErrDocNotFound
}
@@ -0,0 +1,150 @@
package worldmap
import (
"fmt"
"path"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// mapDocumentLoader resolves an existing map document. The caller keeps the
// source-specific path lookup out of the import parser so Load and LoadSource
// share the same PVF import semantics.
type mapDocumentLoader func(candidate string) (string, *dnfpvf.Document, error)
func parseMapWithImports(id int64, docPath string, doc *dnfpvf.Document, load mapDocumentLoader) (Map, error) {
if doc == nil {
return Map{}, fmt.Errorf("map document %q is nil", docPath)
}
if load == nil {
return Map{}, fmt.Errorf("map document loader is nil")
}
cache := make(map[string]Map)
visiting := make(map[string]struct{})
var parse func(string, *dnfpvf.Document) (Map, error)
parse = func(currentPath string, currentDoc *dnfpvf.Document) (Map, error) {
key := pathKey(currentPath)
if parsed, ok := cache[key]; ok {
return cloneMap(parsed), nil
}
if _, exists := visiting[key]; exists {
return Map{}, fmt.Errorf("cyclic map import at %q", currentPath)
}
visiting[key] = struct{}{}
defer delete(visiting, key)
parsed := ParseMap(0, currentPath, currentDoc)
for _, reference := range mapImportReferences(currentDoc) {
importPath, importDoc, err := loadMapImport(currentPath, reference, load)
if err != nil {
return Map{}, err
}
imported, err := parse(importPath, importDoc)
if err != nil {
return Map{}, err
}
parsed = mergeImportedMapStaticSpawns(parsed, imported)
}
cache[key] = cloneMap(parsed)
return parsed, nil
}
parsed, err := parse(docPath, doc)
if err != nil {
return Map{}, err
}
parsed.ID = id
return parsed, nil
}
func mapImportReferences(doc *dnfpvf.Document) []string {
sections, _ := rawSections(doc)
var out []string
for _, section := range sections {
if sectionKey(section.Name) != "import script" {
continue
}
for _, reference := range texts(section) {
reference = strings.TrimSpace(reference)
if reference != "" {
out = append(out, reference)
}
}
}
return out
}
func loadMapImport(ownerPath, reference string, load mapDocumentLoader) (string, *dnfpvf.Document, error) {
var lastErr error
for _, candidate := range mapImportCandidates(ownerPath, reference) {
resolvedPath, doc, err := load(candidate)
if err == nil && doc != nil {
return resolvedPath, doc, nil
}
if err != nil {
lastErr = err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("no matching document")
}
return "", nil, fmt.Errorf("resolve map import %q from %q: %w", reference, ownerPath, lastErr)
}
func mapImportCandidates(ownerPath, reference string) []string {
reference = strings.TrimSpace(strings.ReplaceAll(reference, "\\", "/"))
for strings.HasPrefix(reference, "./") || strings.HasPrefix(reference, "/") {
reference = strings.TrimPrefix(reference, "./")
reference = strings.TrimPrefix(reference, "/")
}
if reference == "" {
return nil
}
ownerPath = strings.TrimSpace(strings.ReplaceAll(ownerPath, "\\", "/"))
var candidates []string
add := func(candidate string) {
candidate = strings.TrimPrefix(path.Clean(candidate), "./")
if candidate == "." || candidate == "" {
return
}
for _, existing := range candidates {
if pathKey(existing) == pathKey(candidate) {
return
}
}
candidates = append(candidates, candidate)
}
add(reference)
add(path.Join(path.Dir(ownerPath), reference))
parts := strings.Split(ownerPath, "/")
for index, part := range parts {
if !strings.EqualFold(part, "map") {
continue
}
add(path.Join(strings.Join(parts[:index+1], "/"), reference))
break
}
return candidates
}
// Imported town maps commonly carry the physical NPC and passive-object
// placements shared by several maps. Keep the root map metadata authoritative
// while exposing those real imported placements to the runtime catalog.
func mergeImportedMapStaticSpawns(base, imported Map) Map {
imported = cloneMap(imported)
base.PathgatePositions = append(imported.PathgatePositions, base.PathgatePositions...)
base.PathgateObjects = append(imported.PathgateObjects, base.PathgateObjects...)
base.Portals = buildPortals(base.PathgatePositions, base.PathgateObjects)
base.AnimationObjects = append(imported.AnimationObjects, base.AnimationObjects...)
base.PassiveObjects = append(imported.PassiveObjects, base.PassiveObjects...)
base.SpecialPassiveObjects = append(imported.SpecialPassiveObjects, base.SpecialPassiveObjects...)
base.NPCs = append(imported.NPCs, base.NPCs...)
base.AICharacters = append(imported.AICharacters, base.AICharacters...)
base.TownMovableAreas = append(imported.TownMovableAreas, base.TownMovableAreas...)
base.DungeonMovableAreas = append(imported.DungeonMovableAreas, base.DungeonMovableAreas...)
return base
}
@@ -0,0 +1,48 @@
package worldmap
import (
"context"
"os"
"reflect"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFBlackSmokeKeepsTogAuthoredNormalRank(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the real monster-rank regression")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipAreas: true, CompactRuntime: true})
if err != nil {
t.Fatal(err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonTopology(resolver, 243, 0)
if err != nil {
t.Fatal(err)
}
coordinate := RoomCoordinate{X: 1, Y: 0}
wantRanks := []string{"[normal]"}
for read := 0; read < 3; read++ {
room, ok := topology.Room(coordinate)
if !ok || room.Map == nil || room.Map.Map.ID != 58437 {
t.Fatalf("read %d Black Smoke room=%+v ok=%t", read, room, ok)
}
if len(room.Map.Map.Monsters) != 1 || room.Map.Map.Monsters[0].MonsterID != 56675 {
t.Fatalf("read %d Black Smoke monsters=%+v", read, room.Map.Map.Monsters)
}
if got := monsterSpawnRanks(room.Map.Map.Monsters); !reflect.DeepEqual(got, wantRanks) {
t.Fatalf("read %d Tog ranks=%v, want PVF-authored %v", read, got, wantRanks)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,872 @@
package worldmap
import (
"slices"
"testing"
)
func TestParseDungeonMetadataRepeatedMazesAndExtensions(t *testing.T) {
doc := parseTestDocument(t, "dungeon/test.dgn", `
[name]
`+"`Test Dungeon`"+`
[explain]
`+"`Structured from local PVF`"+`
[cutscene image]
`+"`Map/Test/loading.img`"+` 3
[minimum required level]
10
[basis level]
20
[experience increasing point]
1.25
[dungeon type]
`+"`[normal]`"+`
[tutorial dungeon]
1
[recommended level]
10 30
[fatigue]
8
[raid dungeon]
1
[maze info]
[size]
3 2
[greed]
`+"`BBFFEE`"+`
[map specification]
`+"`map`"+` 0 0 100 `+"`map`"+` 1 0 101 102 `+"`boss`"+` 2 1 103
[start map]
0 0 7
[boss map]
2 1
[quest connection]
0 900 -1
[boss map specification]
2 1 103
[layered map specification]
1 1 104 105
[maze minimap ani icon]
0 1 0 `+"`ui/maze.ani`"+` 1
[randomized object creation]
[select]
1
[regenerate]
0
[minimap icon]
2
[object]
[map]
1 0
[index]
64024
[team]
[monster]
[pos]
563 197
[/object]
[/randomized object creation]
[clear condition]
[destroy object]
80458 1
[/clear condition]
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 200
[start map]
0 0
[boss map]
0 0
[fatigue result]
4
`)
dungeon := ParseDungeon(77, "dungeon/test.dgn", doc)
if dungeon.Metadata.Name != "Test Dungeon" || !dungeon.Metadata.MinimumRequiredLevel.Set || dungeon.Metadata.MinimumRequiredLevel.Value != 10 {
t.Fatalf("unexpected metadata: %+v", dungeon.Metadata)
}
if !dungeon.Metadata.ExperienceIncreasingPoint.Set || dungeon.Metadata.ExperienceIncreasingPoint.Value != 1.25 {
t.Fatalf("unexpected numeric metadata: %+v", dungeon.Metadata.ExperienceIncreasingPoint)
}
if !dungeon.Metadata.TutorialDungeon.Set || dungeon.Metadata.TutorialDungeon.Value != 1 {
t.Fatalf("tutorial dungeon metadata missing: %+v", dungeon.Metadata.TutorialDungeon)
}
if dungeon.Metadata.IntegerValues["fatigue"] != 8 || dungeon.Metadata.IntegerValues["fatigue result"] != 4 {
t.Fatalf("stable integer metadata missing: %+v", dungeon.Metadata.IntegerValues)
}
if len(dungeon.Mazes) != 2 || dungeon.Mazes[0].Index != 0 || dungeon.Mazes[1].Index != 1 {
t.Fatalf("repeated mazes = %+v", dungeon.Mazes)
}
first := dungeon.Mazes[0]
if first.Width.Value != 3 || first.Height.Value != 2 || first.Greed != "BBFFEE" || len(first.MapSpecifications) != 3 {
t.Fatalf("unexpected first maze: %+v", first)
}
if got := first.MapSpecifications[1].MapIDs; len(got) != 2 || got[0] != 101 || got[1] != 102 {
t.Fatalf("map candidates = %+v", got)
}
if first.Start == nil || first.Start.X != 0 || len(first.Start.Params) != 1 || first.Boss == nil || first.Boss.X != 2 {
t.Fatalf("start/boss points = start=%+v boss=%+v", first.Start, first.Boss)
}
if len(dungeon.Extensions) != 1 || dungeon.Extensions[0].Name != "raid dungeon" || dungeon.Extensions[0].Kind != SectionIntegers || dungeon.Extensions[0].Integers[0] != 1 {
t.Fatalf("local metadata extension = %+v", dungeon.Extensions)
}
if len(first.Extensions) != 1 || first.Extensions[0].Name != "maze minimap ani icon" || first.Extensions[0].Kind != SectionMixed || first.Extensions[0].Texts[0] != "ui/maze.ani" {
t.Fatalf("local maze extension = %+v", first.Extensions)
}
if len(first.OpaqueSections) < 2 || len(first.SourceSections) == 0 || len(dungeon.SourceSections) != len(doc.Sections) {
t.Fatalf("raw ownership missing: maze opaque=%+v source=%d dungeon_source=%d", first.OpaqueSections, len(first.SourceSections), len(dungeon.SourceSections))
}
if len(first.BossSpecifications) != 1 || first.BossSpecifications[0].MapIDs[0] != 103 || len(first.LayeredSpecifications) != 1 || len(first.LayeredSpecifications[0].MapIDs) != 2 {
t.Fatalf("boss/layered specifications = boss=%+v layered=%+v", first.BossSpecifications, first.LayeredSpecifications)
}
if len(first.RandomizedObjects) != 1 || first.RandomizedObjects[0].MinimapIcon.Value != 2 || len(first.RandomizedObjects[0].Objects) != 1 {
t.Fatalf("randomized object script = %+v", first.RandomizedObjects)
}
object := first.RandomizedObjects[0].Objects[0]
if object.ObjectIndex.Value != 64024 || object.Team != "monster" || object.Position.X != 563 || object.MazeCoordinate.X != 1 {
t.Fatalf("randomized object = %+v", object)
}
if len(first.ClearConditions) != 1 || first.ClearConditions[0].Type != "destroy object" || first.ClearConditions[0].TargetID != 80458 {
t.Fatalf("clear conditions = %+v", first.ClearConditions)
}
}
func TestParseDungeonEscapeHellBelongsToSelectedMaze(t *testing.T) {
doc := parseTestDocument(t, "dungeon/escape_hell.dgn", `
[hell dungeon]
1
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 100
[start map]
0 0
[boss map]
0 0
[maze info]
[escape hell]
1
[size]
1 1
[map specification]
`+"`map`"+` 0 0 200
[start map]
0 0
[boss map]
0 0
`)
dungeon := ParseDungeon(7542, "dungeon/escape_hell.dgn", doc)
if got := dungeon.Metadata.IntegerValues["hell dungeon"]; got != 1 {
t.Fatalf("hell dungeon=%d want 1", got)
}
if len(dungeon.Mazes) != 2 {
t.Fatalf("mazes=%d want 2", len(dungeon.Mazes))
}
if dungeon.Mazes[0].EscapeHell.Set {
t.Fatalf("unmarked maze inherited escape-hell state: %+v", dungeon.Mazes[0].EscapeHell)
}
if got := dungeon.Mazes[1].EscapeHell; !got.Set || got.Value != 1 {
t.Fatalf("selected maze escape-hell=%+v want {Value:1 Set:true}", got)
}
if _, leaked := dungeon.Metadata.IntegerValues["escape hell"]; leaked {
t.Fatalf("maze escape-hell leaked into dungeon metadata: %+v", dungeon.Metadata.IntegerValues)
}
}
func TestParseDungeonBossRoomEntranceConditionBelongsToCurrentMaze(t *testing.T) {
doc := parseTestDocument(t, "dungeon/boss_gate.dgn", `
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 100
[start map]
0 0
[boss map]
0 0
[boss room entrance condition]
`+"`[clear action]` `[time condition]` 1 0 `[hunt monster]` 2 69201 0 2 69210 0 2"+`
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 200
[start map]
0 0
[boss map]
0 0
[boss room entrance condition]
`+"`[clear action]` `[time condition]` 7 9 `[hunt monster]` 1 70001 4 3"+`
`)
dungeon := ParseDungeon(2005, "dungeon/boss_gate.dgn", doc)
if len(dungeon.Mazes) != 2 {
t.Fatalf("mazes=%+v", dungeon.Mazes)
}
first := dungeon.Mazes[0].BossRoomEntranceCondition
if first == nil || first.Action != "clear action" || len(first.TimeCondition) != 2 ||
first.TimeCondition[0] != 1 || first.TimeCondition[1] != 0 || len(first.HuntMonsters) != 2 {
t.Fatalf("first boss-room entrance condition=%+v diagnostics=%+v", first, dungeon.Mazes[0].Diagnostics)
}
if got := first.HuntMonsters[0]; got.TargetID != 69201 || got.Opaque != 0 || got.Count != 2 {
t.Fatalf("first hunt target=%+v", got)
}
if got := first.HuntMonsters[1]; got.TargetID != 69210 || got.Opaque != 0 || got.Count != 2 {
t.Fatalf("second hunt target=%+v", got)
}
second := dungeon.Mazes[1].BossRoomEntranceCondition
if second == nil || second.TimeCondition[0] != 7 || second.TimeCondition[1] != 9 ||
len(second.HuntMonsters) != 1 || second.HuntMonsters[0] != (BossRoomEntranceHuntMonster{TargetID: 70001, Opaque: 4, Count: 3}) {
t.Fatalf("second boss-room entrance condition=%+v diagnostics=%+v", second, dungeon.Mazes[1].Diagnostics)
}
for _, section := range dungeon.OpaqueSections {
if sectionKey(section.Name) == "boss room entrance condition" {
t.Fatalf("per-maze boss-room gate leaked into dungeon opaque sections: %+v", section)
}
}
cloned := cloneDungeon(dungeon)
cloned.Mazes[0].BossRoomEntranceCondition.TimeCondition[0] = 99
cloned.Mazes[0].BossRoomEntranceCondition.HuntMonsters[0].Count = 99
if first.TimeCondition[0] != 1 || first.HuntMonsters[0].Count != 2 {
t.Fatalf("clone aliases source condition: source=%+v clone=%+v", first, cloned.Mazes[0].BossRoomEntranceCondition)
}
}
func TestParseDungeonEventMonsterMetadataRemainsDungeonWide(t *testing.T) {
doc := parseTestDocument(t, "dungeon/event_monster.dgn", `
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 58708
[start map]
0 0
[boss map]
0 0
[event monster random map]
0
[event monster]
1 1000 21100 1 1000 1 1 5
[event monster2]
2 2000 31100
`)
dungeon := ParseDungeon(26, "dungeon/event_monster.dgn", doc)
if !dungeon.Metadata.EventMonsterRandomMap.Set || dungeon.Metadata.EventMonsterRandomMap.Value != 0 {
t.Fatalf("event-monster random-map selector=%+v", dungeon.Metadata.EventMonsterRandomMap)
}
if len(dungeon.Metadata.EventMonsters) != 2 ||
dungeon.Metadata.EventMonsters[0].Kind != "event monster" ||
dungeon.Metadata.EventMonsters[1].Kind != "event monster2" {
t.Fatalf("event-monster definitions=%+v", dungeon.Metadata.EventMonsters)
}
want := []int64{1, 1000, 21100, 1, 1000, 1, 1, 5}
if !slices.Equal(dungeon.Metadata.EventMonsters[0].Values, want) {
t.Fatalf("event-monster values=%v want=%v", dungeon.Metadata.EventMonsters[0].Values, want)
}
cloned := cloneDungeon(dungeon)
cloned.Metadata.EventMonsters[0].Values[0] = 99
if dungeon.Metadata.EventMonsters[0].Values[0] != 1 {
t.Fatalf("clone aliases source event-monster row: source=%v clone=%v",
dungeon.Metadata.EventMonsters[0].Values, cloned.Metadata.EventMonsters[0].Values)
}
compactDungeonForRuntime(&cloned)
if len(cloned.Metadata.EventMonsters) != 2 || !cloned.Metadata.EventMonsterRandomMap.Set {
t.Fatalf("runtime compaction removed typed event-monster metadata: %+v", cloned.Metadata)
}
}
func TestParseDungeonKeepsEventAndAddedAdmissionItemsSeparate(t *testing.T) {
doc := parseTestDocument(t, "dungeon/event_mixed.dgn", `
[required item]
1001 2 0
[/required item]
[event required item]
2001 1 0
[/event required item]
[added required item]
3001 3 0
[/added required item]
`)
dungeon := ParseDungeon(9000, "dungeon/event_mixed.dgn", doc)
if len(dungeon.Metadata.RequiredItems) != 1 || dungeon.Metadata.RequiredItems[0] != (TicketItem{ItemID: 1001, Count: 2}) {
t.Fatalf("ordinary required items=%+v", dungeon.Metadata.RequiredItems)
}
if len(dungeon.Metadata.EventRequiredItems) != 1 || dungeon.Metadata.EventRequiredItems[0] != (TicketItem{ItemID: 2001, Count: 1}) {
t.Fatalf("event required items=%+v", dungeon.Metadata.EventRequiredItems)
}
if len(dungeon.Metadata.AddedRequiredItems) != 1 || dungeon.Metadata.AddedRequiredItems[0] != (TicketItem{ItemID: 3001, Count: 3}) {
t.Fatalf("added required items=%+v", dungeon.Metadata.AddedRequiredItems)
}
cloned := cloneDungeon(dungeon)
cloned.Metadata.EventRequiredItems[0].Count = 99
cloned.Metadata.AddedRequiredItems[0].Count = 99
if dungeon.Metadata.EventRequiredItems[0].Count != 1 || dungeon.Metadata.AddedRequiredItems[0].Count != 3 {
t.Fatalf("clone aliases admission evidence: source=%+v clone=%+v", dungeon.Metadata, cloned.Metadata)
}
}
func TestParseDungeonBossRoomEntranceConditionRejectsMalformedValues(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "wrong action", payload: "`[open action]` `[time condition]` 1 0 `[hunt monster]` 1 69201 0 2"},
{name: "missing time value", payload: "`[clear action]` `[time condition]` 1 `[hunt monster]` 1 69201 0 2"},
{name: "wrong hunt label", payload: "`[clear action]` `[time condition]` 1 0 `[hunt apc]` 1 69201 0 2"},
{name: "truncated row", payload: "`[clear action]` `[time condition]` 1 0 `[hunt monster]` 2 69201 0 2 69210 0"},
{name: "trailing row", payload: "`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 69201 0 2 7"},
{name: "non-positive target", payload: "`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 0 0 2"},
{name: "non-positive count", payload: "`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 69201 0 0"},
{name: "non-integer row", payload: "`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 69201 `opaque` 2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
doc := parseTestDocument(t, "dungeon/boss_gate_bad.dgn", `
[maze info]
[size]
1 1
[boss room entrance condition]
`+tt.payload+`
`)
dungeon := ParseDungeon(2005, "dungeon/boss_gate_bad.dgn", doc)
if len(dungeon.Mazes) != 1 || dungeon.Mazes[0].BossRoomEntranceCondition != nil || len(dungeon.Mazes[0].Diagnostics) == 0 {
t.Fatalf("malformed condition accepted: maze=%+v", dungeon.Mazes)
}
})
}
}
func TestParseDungeonBossRoomEntranceConditionRejectsDuplicates(t *testing.T) {
doc := parseTestDocument(t, "dungeon/boss_gate_duplicate.dgn", `
[maze info]
[size]
1 1
[boss room entrance condition]
`+"`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 69201 0 2"+`
[boss room entrance condition]
`+"`[clear action]` `[time condition]` 1 0 `[hunt monster]` 1 69210 0 2"+`
`)
dungeon := ParseDungeon(2005, "dungeon/boss_gate_duplicate.dgn", doc)
if len(dungeon.Mazes) != 1 || dungeon.Mazes[0].BossRoomEntranceCondition != nil || len(dungeon.Mazes[0].Diagnostics) == 0 {
t.Fatalf("duplicate condition accepted: maze=%+v", dungeon.Mazes)
}
}
func TestParseDungeonBloodAltarMetadata(t *testing.T) {
tests := []struct {
name string
body string
wantKind BloodAltarDungeonKind
wantRound int64
valid bool
}{
{name: "endless", body: "[blood dungeon]\n1\n[blood max round]\n15", wantKind: BloodAltarDungeonKindEndless, wantRound: 15, valid: true},
{name: "ultimate", body: "[blood dungeon]\n2\n[blood max round]\n7", wantKind: BloodAltarDungeonKindUltimate, wantRound: 7, valid: true},
{name: "empty kind rejected", body: "[blood dungeon]\n[blood max round]\n15", valid: false},
{name: "invalid kind", body: "[blood dungeon]\n3\n[blood max round]\n15"},
{name: "invalid round", body: "[blood dungeon]\n1\n[blood max round]\n256"},
{name: "extra round field", body: "[blood dungeon]\n1\n[blood max round]\n7 8"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dungeon := ParseDungeon(11006, "dungeon/altar.dgn", parseTestDocument(t, "dungeon/altar.dgn", tt.body))
if !dungeon.Metadata.Flags["blood dungeon"] {
t.Fatalf("blood dungeon compatibility flag missing: %+v", dungeon.Metadata.Flags)
}
if tt.valid {
if dungeon.BloodAltarKind != tt.wantKind || dungeon.BloodMaxRounds != tt.wantRound ||
dungeon.Metadata.IntegerValues["blood max round"] != tt.wantRound {
t.Fatalf("blood altar metadata = kind=%d rounds=%d integers=%+v diagnostics=%+v",
dungeon.BloodAltarKind, dungeon.BloodMaxRounds, dungeon.Metadata.IntegerValues, dungeon.Diagnostics)
}
return
}
if len(dungeon.Diagnostics) == 0 || (dungeon.BloodAltarKind != 0 && dungeon.BloodMaxRounds != 0) {
t.Fatalf("invalid blood altar metadata accepted: %+v", dungeon)
}
})
}
}
func TestParseDungeonInfernoMetadataAfterMazeUsesClosedQuestList(t *testing.T) {
doc := parseTestDocument(t, "dungeon/inferno.dgn", `
[inferno dungeon]
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 80117
[start map]
0 0
[boss map]
0 0
[inferno quest]
10104
[/inferno quest]
`)
dungeon := ParseDungeon(7527, "dungeon/inferno.dgn", doc)
if !dungeon.Metadata.InfernoDungeon || len(dungeon.Metadata.InfernoQuestIDs) != 1 || dungeon.Metadata.InfernoQuestIDs[0] != 10104 {
t.Fatalf("inferno metadata=%+v", dungeon.Metadata)
}
}
func TestParseDungeonSpecialPassiveObjectItemGroups(t *testing.T) {
doc := parseTestDocument(t, "dungeon/passive_items.dgn", `
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 76512
[start map]
0 0
[boss map]
0 0
[special passive object item]
0 -1 3 1069 1500 1071 500 1072 3000 1 -1 0 2 85 1 10099777 9400
[/special passive object item]
`)
dungeon := ParseDungeon(102, "dungeon/passive_items.dgn", doc)
if len(dungeon.Mazes) != 1 {
t.Fatalf("mazes=%+v", dungeon.Mazes)
}
// Record 1 is declared empty (`1 -1 0`), so the following record sits at
// vector position 2 and carries its own level override 85. The table is
// dungeon-wide even when the file places it under a maze.
if len(dungeon.Mazes[0].SpecialPassiveObjectItems) != 0 {
t.Fatalf("maze retained route-local rows=%+v", dungeon.Mazes[0].SpecialPassiveObjectItems)
}
rows := dungeon.Metadata.SpecialPassiveObjectItems
if len(rows) != 4 || rows[0].Index != 0 || rows[1].Index != 0 || rows[2].Index != 0 || rows[3].Index != 2 ||
rows[0].ItemID != 1069 || rows[0].DropRate != 1500 || rows[0].LevelOverride != -1 ||
rows[3].LevelOverride != 85 || rows[3].ItemID != 10099777 || rows[3].DropRate != 9400 {
t.Fatalf("rows=%+v", rows)
}
}
func TestParseParticularHellPromotesTrailingPassiveItemsToEveryMaze(t *testing.T) {
doc := parseTestDocument(t, "dungeon/particular_hell.dgn", `
[dungeon type]
`+"`particular hell`"+`
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 100
[start map]
0 0
[boss map]
0 0
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 101
[start map]
0 0
[boss map]
0 0
[special passive object item]
0 -1 1 3276 5000
[/special passive object item]
`)
dungeon := ParseDungeon(316, "dungeon/particular_hell.dgn", doc)
rows := dungeon.Metadata.SpecialPassiveObjectItems
if len(rows) != 1 || rows[0].ItemID != 3276 || rows[0].DropRate != 5000 {
t.Fatalf("particular-hell rows=%+v", rows)
}
for index, maze := range dungeon.Mazes {
if len(maze.SpecialPassiveObjectItems) != 0 {
t.Fatalf("maze=%d retained promoted rows=%+v", index, maze.SpecialPassiveObjectItems)
}
}
}
func TestParseSpecialDungeonPromotesTrailingPassiveItemsToEveryMaze(t *testing.T) {
doc := parseTestDocument(t, "dungeon/special.dgn", `
[special dungeon]
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 100
[start map]
0 0
[boss map]
0 0
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 101
[start map]
0 0
[boss map]
0 0
[special passive object item]
0 -1 1 10088439 10000
[/special passive object item]
`)
dungeon := ParseDungeon(2007, "dungeon/special.dgn", doc)
rows := dungeon.Metadata.SpecialPassiveObjectItems
if len(rows) != 1 || rows[0].Index != 0 || rows[0].ItemID != 10088439 || rows[0].DropRate != 10000 {
t.Fatalf("special-dungeon rows=%+v", rows)
}
for index, maze := range dungeon.Mazes {
if len(maze.SpecialPassiveObjectItems) != 0 {
t.Fatalf("maze=%d retained promoted rows=%+v", index, maze.SpecialPassiveObjectItems)
}
}
}
func TestParseDungeonInfernoRouteMetadata(t *testing.T) {
doc := parseTestDocument(t, "dungeon/infernomode/time_rift.dgn", `
[hell dungeon]
1
[hell party required item count]
24
[inferno dungeon]
[select dungeon normal disable]
[maze info]
[size]
5 2
[maze chance rate]
11875
[greed]
`+"`AAAAAAAAII`"+`
[map specification]
`+"`map`"+` 0 1 80117 `+"`boss`"+` 4 0 80297 80298
[start map]
0 1
[boss map]
4 0
[maze info]
[size]
5 2
[maze chance rate]
2500
[inferno special]
[greed]
`+"`JJFFFFFFEE`"+`
[map specification]
`+"`map`"+` 0 1 80165 `+"`boss`"+` 4 0 80170
[start map]
0 1
[boss map]
4 0
[inferno quest]
10104
`)
dungeon := ParseDungeon(7527, "dungeon/infernomode/time_rift.dgn", doc)
if !dungeon.Metadata.InfernoDungeon || !dungeon.Metadata.SelectDungeonNormalDisabled ||
!dungeon.Metadata.HellPartyRequiredItemCount.Set || dungeon.Metadata.HellPartyRequiredItemCount.Value != 24 {
t.Fatalf("inferno metadata=%+v", dungeon.Metadata)
}
if got := dungeon.Metadata.InfernoQuestIDs; len(got) != 1 || got[0] != 10104 {
t.Fatalf("inferno quests=%v", got)
}
if len(dungeon.Mazes) != 2 || !dungeon.Mazes[0].ChanceRate.Set || dungeon.Mazes[0].ChanceRate.Value != 11875 ||
!dungeon.Mazes[1].InfernoSpecial || dungeon.Mazes[1].ChanceRate.Value != 2500 {
t.Fatalf("inferno mazes=%+v", dungeon.Mazes)
}
}
func TestParseDungeonWarpMapConditionsPreserveOrderedPairsAndAmbiguousSources(t *testing.T) {
doc := parseTestDocument(t, "dungeon/warp.dgn", `
[maze info]
[size]
3 3
[map specification]
`+"`map`"+` 0 0 100 `+"`map`"+` 2 2 101
[start map]
0 0
[boss map]
2 2
[warp map condition]
[source grid pos]
1 1
[dest grid pos]
2 2
[source grid pos]
1 1
[dest grid pos]
0 0
[source grid pos]
-1 -1
[dest grid pos]
0 0
[/warp map condition]
`)
dungeon := ParseDungeon(4000, "dungeon/warp.dgn", doc)
want := []WarpMapCondition{
{Source: Point{X: 1, Y: 1}, Destination: Point{X: 2, Y: 2}},
{Source: Point{X: 1, Y: 1}, Destination: Point{X: 0, Y: 0}},
{Source: Point{X: -1, Y: -1}, Destination: Point{X: 0, Y: 0}},
}
if len(dungeon.WarpMapConditions) != len(want) {
t.Fatalf("warp rules=%+v diagnostics=%+v", dungeon.WarpMapConditions, dungeon.Diagnostics)
}
for index := range want {
if dungeon.WarpMapConditions[index] != want[index] {
t.Fatalf("warp rule %d=%+v want=%+v", index, dungeon.WarpMapConditions[index], want[index])
}
}
if len(dungeon.Mazes) != 1 || len(dungeon.Mazes[0].WarpMapConditions) != len(want) {
t.Fatalf("per-maze warp rules=%+v", dungeon.Mazes)
}
for index := range want {
if dungeon.Mazes[0].WarpMapConditions[index] != want[index] {
t.Fatalf("maze warp rule %d=%+v want=%+v", index, dungeon.Mazes[0].WarpMapConditions[index], want[index])
}
}
}
func TestParseDungeonWarpMapConditionsBelongToPhysicalMaze(t *testing.T) {
doc := parseTestDocument(t, "dungeon/per_maze_warp.dgn", `
[maze info]
[size]
3 3
[map specification]
`+"`map`"+` 0 0 100 `+"`map`"+` 2 2 101
[start map]
0 0
[boss map]
2 2
[warp map condition]
[source grid pos]
2 1
[dest grid pos]
0 0
[/warp map condition]
[maze info]
[size]
3 3
[map specification]
`+"`map`"+` 0 0 200 `+"`map`"+` 2 2 201
[start map]
0 0
[boss map]
2 2
[warp map condition]
[source grid pos]
1 2
[dest grid pos]
2 2
[/warp map condition]
`)
dungeon := ParseDungeon(4000, "dungeon/per_maze_warp.dgn", doc)
if len(dungeon.WarpMapConditions) != 2 || len(dungeon.Mazes) != 2 {
t.Fatalf("dungeon=%+v", dungeon)
}
want := []WarpMapCondition{
{Source: Point{X: 2, Y: 1}, Destination: Point{X: 0, Y: 0}},
{Source: Point{X: 1, Y: 2}, Destination: Point{X: 2, Y: 2}},
}
for mazeIndex := range dungeon.Mazes {
got := dungeon.Mazes[mazeIndex].WarpMapConditions
if len(got) != 1 || got[0] != want[mazeIndex] {
t.Fatalf("maze %d warp rules=%+v want=%+v", mazeIndex, got, want[mazeIndex])
}
}
}
func TestParseDungeonLostLandDurations(t *testing.T) {
doc := parseTestDocument(t, "dungeon/lost_land.dgn", `
[dungeon type]
`+"`lost land`"+`
[lost land parameters]
120000 120000
[limit time]
600 1200
`)
dungeon := ParseDungeon(4000, "dungeon/lost_land.dgn", doc)
if got := dungeon.Metadata.LostLandParameters; !got.Set || got.DayMilliseconds != 120000 || got.NightMilliseconds != 120000 {
t.Fatalf("lost land parameters=%+v", got)
}
if got := dungeon.Metadata.LimitTime; !got.Set || got.PrimarySeconds != 600 || got.SecondarySeconds != 1200 {
t.Fatalf("limit time=%+v", got)
}
for _, section := range dungeon.OpaqueSections {
if key := sectionKey(section.Name); key == "lost land parameters" || key == "limit time" {
t.Fatalf("typed lost-land section retained as opaque: %s", key)
}
}
}
func TestParseDungeonLostLandDurationsRejectMalformedValues(t *testing.T) {
doc := parseTestDocument(t, "dungeon/lost_land_bad.dgn", `
[lost land parameters]
120000 0
[limit time]
600
`)
dungeon := ParseDungeon(4000, "dungeon/lost_land_bad.dgn", doc)
if dungeon.Metadata.LostLandParameters.Set || dungeon.Metadata.LimitTime.Set {
t.Fatalf("malformed durations parsed: lost_land=%+v limit=%+v", dungeon.Metadata.LostLandParameters, dungeon.Metadata.LimitTime)
}
if len(dungeon.Diagnostics) != 2 {
t.Fatalf("diagnostics=%+v, want two typed duration errors", dungeon.Diagnostics)
}
}
func TestParseDungeonLostLandLimitTimeInsideClearCondition(t *testing.T) {
doc := parseTestDocument(t, "dungeon/lost_land_scoped.dgn", `
[maze info]
[size]
1 1
[map specification]
`+"`map`"+` 0 0 11200
[start map]
0 0
[boss map]
0 0
[clear condition]
[limit time]
600 1200
[/clear condition]
`)
dungeon := ParseDungeon(4000, "dungeon/lost_land_scoped.dgn", doc)
if got := dungeon.Metadata.LimitTime; !got.Set || got.PrimarySeconds != 600 || got.SecondarySeconds != 1200 {
t.Fatalf("scoped limit time=%+v", got)
}
}
func TestParseDungeonMalformedMazeKeepsRawAndDiagnostics(t *testing.T) {
doc := parseTestDocument(t, "dungeon/bad.dgn", `
[name]
`+"`Bad Dungeon`"+`
[maze info]
[size]
`+"`wide`"+` 2
[map specification]
`+"`map`"+` 0 `+"`bad-y`"+` 100
[start map]
1
[future maze node]
{ `+"`payload`"+` = 4 }
`)
dungeon := ParseDungeon(78, "dungeon/bad.dgn", doc)
if len(dungeon.Mazes) != 1 || len(dungeon.Mazes[0].Diagnostics) < 3 {
t.Fatalf("missing malformed diagnostics: %+v", dungeon.Mazes)
}
maze := dungeon.Mazes[0]
if len(maze.MapSpecifications) != 0 || maze.Start != nil || len(maze.UnknownSections) != 1 || len(maze.Extensions) != 1 {
t.Fatalf("malformed values crossed boundaries: %+v", maze)
}
if maze.Extensions[0].Kind != SectionMixed || len(maze.Extensions[0].Tokens) != 5 {
t.Fatalf("future extension tokens lost: %+v", maze.Extensions)
}
}
func TestParseDungeonTutorialMetadataAfterMazeKeepsDungeonOwnership(t *testing.T) {
doc := parseTestDocument(t, "dungeon/tutorial_after_maze.dgn", `
[name]
`+"`Profession Tutorial`"+`
[maze info]
[size]
1 1
[greed]
`+"`A`"+`
[map specification]
`+"`map`"+` 0 0 100
[start map]
0 0
[boss map]
0 0
[tutorial dungeon]
1
[tutorial dungeon]
1
`)
dungeon := ParseDungeon(7110, "dungeon/tutorial_after_maze.dgn", doc)
if !dungeon.Metadata.TutorialDungeon.Set || dungeon.Metadata.TutorialDungeon.Value != 1 {
t.Fatalf("post-maze tutorial metadata missing: %+v", dungeon.Metadata.TutorialDungeon)
}
if len(dungeon.Mazes) != 1 {
t.Fatalf("tutorial mazes=%+v", dungeon.Mazes)
}
for _, section := range dungeon.Mazes[0].UnknownSections {
if sectionKey(section.Name) == "tutorial dungeon" {
t.Fatalf("tutorial metadata leaked into maze unknown sections: %+v", dungeon.Mazes[0].UnknownSections)
}
}
for _, extension := range dungeon.Mazes[0].Extensions {
if sectionKey(extension.Name) == "tutorial dungeon" {
t.Fatalf("tutorial metadata leaked into maze extensions: %+v", dungeon.Mazes[0].Extensions)
}
}
}
func TestDungeonExtensionsClassifyLocalPVFValueShapes(t *testing.T) {
doc := parseTestDocument(t, "dungeon/extensions.dgn", `
[local flag]
[local integers]
1 -2 3
[local numbers]
1.5 2
[local texts]
`+"`alpha` beta"+`
[local mixed]
1 `+"`alpha`"+` { 2 }
`)
dungeon := ParseDungeon(79, "dungeon/extensions.dgn", doc)
if len(dungeon.Extensions) != 5 || len(dungeon.UnknownSections) != 5 {
t.Fatalf("extensions/raw = %d/%d", len(dungeon.Extensions), len(dungeon.UnknownSections))
}
want := []SectionValueKind{SectionFlag, SectionIntegers, SectionNumbers, SectionTexts, SectionMixed}
for i, kind := range want {
if dungeon.Extensions[i].Kind != kind {
t.Errorf("extension %d kind = %s, want %s: %+v", i, dungeon.Extensions[i].Kind, kind, dungeon.Extensions[i])
}
}
if len(dungeon.Extensions[4].Tokens) != 5 || len(dungeon.Extensions[4].Symbols) != 2 {
t.Fatalf("mixed extension did not retain structure: %+v", dungeon.Extensions[4])
}
}
func TestDungeonNestedMetadataBlockAfterMazeKeepsDungeonOwnership(t *testing.T) {
doc := parseTestDocument(t, "dungeon/scoped.dgn", `
[maze info]
[size]
1 1
[greed]
`+"`A`"+`
[start map]
0 0
[boss map]
0 0
[monsterapc diff table]
[normal]
[attack]
1.25
[future balance field]
7
[/normal]
[/monsterapc diff table]
`)
dungeon := ParseDungeon(80, "dungeon/scoped.dgn", doc)
if len(dungeon.Mazes) != 1 {
t.Fatalf("mazes = %+v", dungeon.Mazes)
}
if len(dungeon.OpaqueSections) != 1 || sectionKey(dungeon.OpaqueSections[0].Name) != "monsterapc diff table" {
t.Fatalf("dungeon metadata block = %+v", dungeon.OpaqueSections)
}
if len(dungeon.UnknownSections) != 3 || len(dungeon.Extensions) != 4 {
t.Fatalf("dungeon scoped extensions = unknown=%+v extensions=%+v", dungeon.UnknownSections, dungeon.Extensions)
}
for _, section := range dungeon.UnknownSections {
if len(section.Scope) == 0 || section.Scope[0] != "monsterapc diff table" {
t.Fatalf("dungeon extension scope = %+v", section)
}
}
if len(dungeon.Mazes[0].UnknownSections) != 0 {
t.Fatalf("metadata children leaked into maze = %+v", dungeon.Mazes[0].UnknownSections)
}
}
@@ -0,0 +1,925 @@
package worldmap
import (
"fmt"
"math"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func ParseMap(id int64, mapPath string, doc *dnfpvf.Document) Map {
result := Map{ID: id, Path: mapPath}
sections, diagnostics := rawSections(doc)
result.SourceSections = sections
result.Diagnostics = append(result.Diagnostics, diagnostics...)
inBackground := false
backgroundIndex := -1
for _, section := range sections {
key := sectionKey(section.Name)
if isClosingSection(key) {
if key == "/background animation" {
inBackground = false
backgroundIndex = -1
}
continue
}
if inBackground {
switch key {
case "ani info":
result.BackgroundAnimations = append(result.BackgroundAnimations, BackgroundAnimation{})
backgroundIndex = len(result.BackgroundAnimations) - 1
case "filename", "layer", "order":
if backgroundIndex < 0 {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "background field appears outside ani info"))
continue
}
value, ok := firstText(section)
if !ok {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected text value"))
continue
}
switch key {
case "filename":
result.BackgroundAnimations[backgroundIndex].Filename = value
case "layer":
result.BackgroundAnimations[backgroundIndex].Layer = value
case "order":
result.BackgroundAnimations[backgroundIndex].Order = value
}
}
continue
}
if scopeContains(section.Scope, "summon") || (key == "summon" && section.Block) {
continue
}
if _, ok := knownMapSections[key]; !ok {
result.UnknownSections = append(result.UnknownSections, cloneRawSection(section))
result.Extensions = append(result.Extensions, structureExtension(section))
continue
}
if key == "tournament enemies" {
if !parseTournamentEnemies(&result, section) {
result.TournamentMalformed = true
}
continue
}
if key == "tournament start area" {
if !parseTournamentStartAreas(&result, section) {
result.TournamentMalformed = true
}
continue
}
if isBloodAltarMapSection(key) {
// These four mutually-referencing tables are parsed together after
// all sections have been collected, so phase indexes can be checked
// against the complete corresponding monster table.
continue
}
if _, ok := opaqueMapSections[key]; ok {
result.OpaqueSections = append(result.OpaqueSections, cloneRawSection(section))
result.Extensions = append(result.Extensions, structureExtension(section))
continue
}
switch key {
case "map name":
setText(&result.Name, section, &result.Diagnostics)
case "player number":
result.PlayerNumber = appendInts(result.PlayerNumber, section, &result.Diagnostics)
case "pvp start area":
result.PVPStartArea = appendInts(result.PVPStartArea, section, &result.Diagnostics)
case "dungeon":
result.DungeonIDs = appendInts(result.DungeonIDs, section, &result.Diagnostics)
if len(result.DungeonIDs) > 0 && !result.DungeonID.Set {
result.DungeonID = OptionalInt{Value: result.DungeonIDs[0], Set: true}
}
case "type":
setText(&result.Type, section, &result.Diagnostics)
case "greed":
setText(&result.Greed, section, &result.Diagnostics)
case "tile":
result.Tiles = append(result.Tiles, texts(section)...)
case "tile files":
result.TileFiles = append(result.TileFiles, parseTileFiles(section, &result.Diagnostics)...)
case "tile map":
row, valid := ints(section)
if !valid {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected integer tile-map row"))
}
result.TileMapRows = append(result.TileMapRows, row)
case "far sight scroll":
setOptionalInt(&result.FarSightScroll, section, &result.Diagnostics)
case "middle sight scroll":
setOptionalInt(&result.MiddleSightScroll, section, &result.Diagnostics)
case "near sight scroll":
setOptionalInt(&result.NearSightScroll, section, &result.Diagnostics)
case "background animation":
inBackground = true
backgroundIndex = -1
case "pathgate pos":
result.PathgatePositions = append(result.PathgatePositions, parsePoints(section, &result.Diagnostics)...)
case "pathgate object":
result.PathgateObjects = appendInts(result.PathgateObjects, section, &result.Diagnostics)
case "sound":
result.Sounds = append(result.Sounds, texts(section)...)
case "animation":
result.AnimationObjects = append(result.AnimationObjects, parseAnimationObjects(section, &result.Diagnostics)...)
case "passive object":
result.PassiveObjects = append(result.PassiveObjects, parsePassiveObjects(section, &result.Diagnostics)...)
case "special passive object":
result.SpecialPassiveObjects = append(result.SpecialPassiveObjects, parseSpecialPassiveObjects(section, &result.Diagnostics)...)
case "monster":
result.Monsters = append(result.Monsters, parseMonsters(section, &result.Diagnostics)...)
case "event monster position":
result.EventMonsterPositions = append(result.EventMonsterPositions, parsePoints3(section, &result.Diagnostics)...)
case "npc":
result.NPCs = append(result.NPCs, parseNPCs(section, &result.Diagnostics)...)
case "ai character":
result.AICharacters = append(result.AICharacters, parseAICharacters(section, &result.Diagnostics)...)
case "town movable area":
result.TownMovableAreas = append(result.TownMovableAreas, parseMovableAreas(section, &result.Diagnostics)...)
case "dungeon movable area":
result.DungeonMovableAreas = append(result.DungeonMovableAreas, parseDungeonMovableAreas(section, &result.Diagnostics)...)
case "fix champion":
setOptionalInt(&result.FixChampion, section, &result.Diagnostics)
case "heroes mode map index":
setOptionalInt(&result.HeroesModeMapIndex, section, &result.Diagnostics)
case "background correction":
setOptionalInt(&result.BackgroundCorrection, section, &result.Diagnostics)
case "background pos":
setOptionalInt(&result.BackgroundPos, section, &result.Diagnostics)
case "foreground pattern alpha":
setOptionalInt(&result.ForegroundPatternAlpha, section, &result.Diagnostics)
case "apc random point":
setOptionalInt(&result.APCRandomPoint, section, &result.Diagnostics)
case "monster lock":
setOptionalInt(&result.MonsterLock, section, &result.Diagnostics)
case "draw monster count":
setOptionalInt(&result.DrawMonsterCount, section, &result.Diagnostics)
case "sort bottom":
setOptionalInt(&result.SortBottom, section, &result.Diagnostics)
case "add gravity":
setOptionalInt(&result.AddGravity, section, &result.Diagnostics)
case "jump power rate":
setOptionalInt(&result.JumpPowerRate, section, &result.Diagnostics)
case "dungeon start area":
result.DungeonStartArea = appendInts(result.DungeonStartArea, section, &result.Diagnostics)
case "screen pos":
result.ScreenPosition = appendInts(result.ScreenPosition, section, &result.Diagnostics)
case "monster team":
result.MonsterTeam = appendInts(result.MonsterTeam, section, &result.Diagnostics)
case "pvp practice start area":
result.PVPPracticeStartArea = appendInts(result.PVPPracticeStartArea, section, &result.Diagnostics)
case "virtual movable area":
result.VirtualMovableArea = appendInts(result.VirtualMovableArea, section, &result.Diagnostics)
case "opening bgm":
result.OpeningBGM = append(result.OpeningBGM, texts(section)...)
case "map loading image path":
setText(&result.MapLoadingImagePath, section, &result.Diagnostics)
case "basic action":
setText(&result.BasicAction, section, &result.Diagnostics)
case "absolute start path":
result.AbsoluteStartPath = append(result.AbsoluteStartPath, texts(section)...)
case "random start map":
setOptionalInt(&result.RandomStartMap, section, &result.Diagnostics)
case "pathgate recognize range":
setOptionalInt(&result.PathgateRecognizeRange, section, &result.Diagnostics)
default:
setMapFlag(&result.Flags, key)
}
}
parseBloodAltarMapSections(&result, sections)
result.Summons = parseMapSummons(sections, &result.Diagnostics)
attachHellParty(sections, &result)
result.Portals = buildPortals(result.PathgatePositions, result.PathgateObjects)
return result
}
func isBloodAltarMapSection(key string) bool {
switch sectionKey(key) {
case "blood monster", "blood phase time", "ultimate monster", "ultimate phase time":
return true
default:
return false
}
}
func parseBloodAltarMapSections(result *Map, sections []RawSection) {
if result == nil {
return
}
result.BloodMonsters = parseBloodAltarMonsterSections(result, sections, "blood monster")
result.UltimateMonsters = parseBloodAltarMonsterSections(result, sections, "ultimate monster")
result.BloodPhases = parseBloodAltarPhaseSections(
result, sections, "blood phase time", result.BloodMonsters, false,
)
result.UltimatePhases = parseBloodAltarPhaseSections(
result, sections, "ultimate phase time", result.UltimateMonsters, true,
)
}
func parseBloodAltarMonsterSections(result *Map, sections []RawSection, name string) []BloodAltarMonster {
var parsed []BloodAltarMonster
valid := true
for _, section := range sections {
if sectionKey(section.Name) != name {
continue
}
rows, err := parseBloodAltarMonsterTable(section)
if err != nil {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, err.Error()))
valid = false
continue
}
parsed = append(parsed, rows...)
}
if !valid {
return nil
}
return parsed
}
func parseBloodAltarMonsterTable(section RawSection) ([]BloodAltarMonster, error) {
const width = 10
tokens := values(section)
if len(tokens) == 0 || len(tokens)%width != 0 {
return nil, fmt.Errorf("blood altar monster table must contain complete 10-field rows; got %d fields", len(tokens))
}
parsed := make([]BloodAltarMonster, 0, len(tokens)/width)
for offset := 0; offset < len(tokens); offset += width {
row := tokens[offset : offset+width]
for field, token := range row {
if !bloodAltarInt32Token(token) {
return nil, fmt.Errorf("blood altar monster row %d field %d must be an int32", offset/width, field)
}
}
if row[0].Int <= 0 ||
!bloodAltarCoordinate(row[2].Int) ||
!bloodAltarCoordinate(row[3].Int) ||
!bloodAltarCoordinate(row[4].Int) ||
row[5].Int < 0 || row[6].Int < 0 || row[7].Int < 0 || row[9].Int < 0 {
return nil, fmt.Errorf("blood altar monster row %d contains an invalid code, coordinate, duration or count", offset/width)
}
parsed = append(parsed, BloodAltarMonster{
MonsterCode: row[0].Int,
TemplateType: row[1].Int,
Position: Point3{X: row[2].Int, Y: row[3].Int, Z: row[4].Int},
DurationMilliseconds: row[5].Int,
SpawnIntervalMilliseconds: row[6].Int,
BaseSpawnCount: row[7].Int,
SpawnCountIncrement: row[8].Int,
BatchCount: row[9].Int,
})
}
return parsed, nil
}
func parseBloodAltarPhaseSections(
result *Map,
sections []RawSection,
name string,
monsters []BloodAltarMonster,
ultimate bool,
) []BloodAltarPhase {
var matching []RawSection
for _, section := range sections {
if sectionKey(section.Name) == name {
matching = append(matching, section)
}
}
if len(matching) == 0 {
return nil
}
if len(monsters) == 0 {
for _, section := range matching {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "blood altar phase table requires a valid matching monster table"))
}
return nil
}
var parsed []BloodAltarPhase
valid := true
for _, section := range matching {
rows, err := parseBloodAltarPhaseTable(section, len(monsters), ultimate)
if err != nil {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, err.Error()))
valid = false
continue
}
parsed = append(parsed, rows...)
}
if !valid {
return nil
}
return parsed
}
func parseBloodAltarPhaseTable(section RawSection, monsterCount int, ultimate bool) ([]BloodAltarPhase, error) {
width := 6
if ultimate {
width = 7
}
tokens := values(section)
if len(tokens) == 0 || len(tokens)%width != 0 {
return nil, fmt.Errorf("blood altar phase table must contain complete %d-field rows; got %d fields", width, len(tokens))
}
parsed := make([]BloodAltarPhase, 0, len(tokens)/width)
for offset := 0; offset < len(tokens); offset += width {
row := tokens[offset : offset+width]
for _, field := range []int{0, 1, 2, 4, 5} {
if !bloodAltarInt32Token(row[field]) {
return nil, fmt.Errorf("blood altar phase row %d field %d must be an int32", offset/width, field)
}
}
scale, ok := bloodAltarScale(row[3])
if !ok {
return nil, fmt.Errorf("blood altar phase row %d scale must be a finite positive number", offset/width)
}
if row[0].Int < 0 || row[1].Int < 0 || row[1].Int >= int64(monsterCount) ||
row[2].Int < 0 || row[5].Int < 0 || row[5].Int > 10 {
return nil, fmt.Errorf("blood altar phase row %d contains an invalid round, monster index, delay or concurrency", offset/width)
}
phase := BloodAltarPhase{
Round: row[0].Int,
MonsterTemplateIndex: row[1].Int,
DelayMilliseconds: row[2].Int,
Scale: scale,
Flag: row[4].Int,
ConcurrentPhaseCount: row[5].Int,
}
if ultimate {
if !isText(row[6]) {
return nil, fmt.Errorf("blood altar phase row %d difficulty must be A or B", offset/width)
}
switch strings.ToUpper(strings.TrimSpace(row[6].Value)) {
case string(BloodAltarDifficultyA):
phase.Difficulty = BloodAltarDifficultyA
case string(BloodAltarDifficultyB):
phase.Difficulty = BloodAltarDifficultyB
default:
return nil, fmt.Errorf("blood altar phase row %d difficulty must be A or B", offset/width)
}
}
parsed = append(parsed, phase)
}
return parsed, nil
}
func bloodAltarInt32Token(token dnfpvf.Token) bool {
const (
minInt32 = int64(-1 << 31)
maxInt32 = int64(1<<31 - 1)
)
return token.Kind == dnfpvf.TokenInt && token.Int >= minInt32 && token.Int <= maxInt32
}
func bloodAltarCoordinate(value int64) bool {
return value >= 0 && value <= 1<<16-1
}
func bloodAltarScale(token dnfpvf.Token) (float64, bool) {
var value float64
switch token.Kind {
case dnfpvf.TokenInt:
value = float64(token.Int)
case dnfpvf.TokenFloat:
value = token.Float
default:
return 0, false
}
return value, value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func parseTournamentEnemies(result *Map, section RawSection) bool {
if result == nil || len(section.Tokens) < 5 {
return false
}
values := make([]dnfpvf.Token, 0, len(section.Tokens))
for _, token := range section.Tokens {
if token.Kind != dnfpvf.TokenSymbol {
values = append(values, token)
}
}
if len(values) < 5 || values[0].Kind != dnfpvf.TokenInt ||
(values[1].Kind != dnfpvf.TokenIdent && values[1].Kind != dnfpvf.TokenString) || (len(values)-2)%3 != 0 {
return false
}
partyCount := values[0].Int
kind := strings.TrimSpace(values[1].Value)
if partyCount <= 0 || (!strings.EqualFold(kind, "[monster]") && !strings.EqualFold(kind, "[apc]")) {
return false
}
for i := 2; i < len(values); i += 3 {
if values[i].Kind != dnfpvf.TokenInt || values[i+1].Kind != dnfpvf.TokenInt {
return false
}
name := strings.TrimSpace(values[i+2].Value)
if name == "" || (values[i+2].Kind != dnfpvf.TokenIdent && values[i+2].Kind != dnfpvf.TokenString) {
return false
}
result.TournamentEnemies = append(result.TournamentEnemies, TournamentEnemyCandidate{
PartyCount: partyCount, Kind: kind, Code: values[i].Int, Strength: values[i+1].Int, Name: name,
})
}
return len(result.TournamentEnemies) > 0
}
func parseTournamentStartAreas(result *Map, section RawSection) bool {
if result == nil {
return false
}
values := make([]int64, 0, len(section.Tokens))
for _, token := range section.Tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
if len(values) == 0 || len(values)%4 != 0 {
return false
}
for i := 0; i < len(values); i += 4 {
if values[i] <= 0 || values[i+1] < 0 || values[i+2] < 0 || values[i+3] < 0 {
return false
}
result.TournamentStartAreas = append(result.TournamentStartAreas, TournamentStartArea{
PartyCount: values[i], X: values[i+1], Y: values[i+2], Direction: values[i+3],
})
}
return len(result.TournamentStartAreas) > 0
}
func parseTileFiles(section RawSection, diagnostics *[]Diagnostic) []TileFile {
tokens := values(section)
var out []TileFile
for index := 0; index < len(tokens); {
if index+1 >= len(tokens) || tokens[index].Kind != dnfpvf.TokenInt || !isText(tokens[index+1]) {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed tile-file pair at token %d", index)))
index++
continue
}
out = append(out, TileFile{Index: tokens[index].Int, Path: tokens[index+1].Value})
index += 2
}
return out
}
func parseMapSummons(sections []RawSection, diagnostics *[]Diagnostic) []MapSummon {
var out []MapSummon
current := -1
for _, section := range sections {
key := sectionKey(section.Name)
if key == "summon" && section.Block {
out = append(out, MapSummon{})
current = len(out) - 1
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
continue
}
if current < 0 {
continue
}
if key == "/summon" {
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
current = -1
continue
}
if isClosingSection(key) {
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
continue
}
if !scopeContains(section.Scope, "summon") {
continue
}
summon := &out[current]
summon.SourceSections = append(summon.SourceSections, cloneRawSection(section))
extension := structureExtension(section)
summon.Extensions = append(summon.Extensions, extension)
if scopeContains(section.Scope, "info") || key == "info" {
summon.Info = append(summon.Info, extension)
}
switch key {
case "summon key":
setOptionalInt(&summon.Key, section, diagnostics)
case "position":
summon.Position = appendInts(summon.Position, section, diagnostics)
case "type":
setText(&summon.Type, section, diagnostics)
case "itemcount":
setOptionalInt(&summon.ItemCount, section, diagnostics)
case "index":
setOptionalInt(&summon.Index, section, diagnostics)
case "life time":
setOptionalInt(&summon.LifeTime, section, diagnostics)
case "info", "distance":
default:
summon.UnknownSections = append(summon.UnknownSections, cloneRawSection(section))
}
}
return out
}
func scopeContains(scope []string, name string) bool {
want := sectionKey(name)
for _, value := range scope {
if sectionKey(value) == want {
return true
}
}
return false
}
func setOptionalInt(target *OptionalInt, section RawSection, diagnostics *[]Diagnostic) {
value, ok := firstInt(section)
if !ok {
*diagnostics = append(*diagnostics, diagnostic(section, "expected integer value"))
return
}
target.Value = value
target.Set = true
}
func setText(target *string, section RawSection, diagnostics *[]Diagnostic) {
value, ok := firstText(section)
if !ok {
*diagnostics = append(*diagnostics, diagnostic(section, "expected text value"))
return
}
*target = value
}
func appendInts(target []int64, section RawSection, diagnostics *[]Diagnostic) []int64 {
parsed, valid := ints(section)
if !valid {
*diagnostics = append(*diagnostics, diagnostic(section, "contains non-integer values"))
}
return append(target, parsed...)
}
func parsePoints(section RawSection, diagnostics *[]Diagnostic) []Point {
parsed, valid := ints(section)
if !valid || len(parsed)%2 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected x/y integer pairs"))
}
out := make([]Point, 0, len(parsed)/2)
for i := 0; i+1 < len(parsed); i += 2 {
out = append(out, Point{X: parsed[i], Y: parsed[i+1]})
}
return out
}
func parsePoints3(section RawSection, diagnostics *[]Diagnostic) []Point3 {
parsed, valid := ints(section)
if !valid || len(parsed)%3 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected x/y/z integer records"))
}
out := make([]Point3, 0, len(parsed)/3)
for i := 0; i+2 < len(parsed); i += 3 {
out = append(out, Point3{X: parsed[i], Y: parsed[i+1], Z: parsed[i+2]})
}
return out
}
func parseAnimationObjects(section RawSection, diagnostics *[]Diagnostic) []AnimationObject {
tokens := values(section)
var out []AnimationObject
for i := 0; i+4 < len(tokens); i += 5 {
if !isText(tokens[i]) || !isText(tokens[i+1]) || !areInts(tokens[i+2:i+5]) {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed animation record at token %d", i)))
break
}
out = append(out, AnimationObject{
Filename: tokens[i].Value, Layer: tokens[i+1].Value,
Position: Point3{X: tokens[i+2].Int, Y: tokens[i+3].Int, Z: tokens[i+4].Int},
})
}
if len(tokens)%5 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "animation section has trailing tokens"))
}
return out
}
func parsePassiveObjects(section RawSection, diagnostics *[]Diagnostic) []PassiveObject {
parsed, valid := ints(section)
if !valid || len(parsed)%4 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected object_id/x/y/flags integer records"))
}
out := make([]PassiveObject, 0, len(parsed)/4)
for i := 0; i+3 < len(parsed); i += 4 {
out = append(out, PassiveObject{ObjectID: parsed[i], X: parsed[i+1], Y: parsed[i+2], Flags: parsed[i+3]})
}
return out
}
func parseSpecialPassiveObjects(section RawSection, diagnostics *[]Diagnostic) []SpecialPassiveObject {
tokens := values(section)
if out, ok := parseZeroSpawnSpecialPassiveObjectRows(tokens); ok {
return out
}
if len(tokens)%4 == 0 && areInts(tokens) {
out := make([]SpecialPassiveObject, 0, len(tokens)/4)
for i := 0; i+3 < len(tokens); i += 4 {
out = append(out, SpecialPassiveObject{PassiveObject: PassiveObject{
ObjectID: tokens[i].Int, X: tokens[i+1].Int, Y: tokens[i+2].Int, Flags: tokens[i+3].Int,
}})
}
return out
}
var out []SpecialPassiveObject
for i := 0; i < len(tokens); {
if i+3 >= len(tokens) || !areInts(tokens[i:i+4]) {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed special object at token %d", i)))
break
}
obj := SpecialPassiveObject{PassiveObject: PassiveObject{
ObjectID: tokens[i].Int, X: tokens[i+1].Int, Y: tokens[i+2].Int, Flags: tokens[i+3].Int,
}}
i += 4
if i >= len(tokens) || tokens[i].Kind != dnfpvf.TokenInt {
out = append(out, obj)
continue
}
spawnCount := tokens[i].Int
if spawnCount < 0 || spawnCount > int64((len(tokens)-i-1)/6) {
out = append(out, obj)
continue
}
i++
for n := int64(0); n < spawnCount; n++ {
if i < len(tokens) && isHellPartyOpen(tokens[i]) {
i++
for {
if i >= len(tokens) {
*diagnostics = append(*diagnostics, diagnostic(section, "hellparty block is not closed"))
return out
}
if isHellPartyClose(tokens[i]) {
i++
break
}
if i+2 >= len(tokens) || !areInts(tokens[i:i+3]) {
*diagnostics = append(*diagnostics, diagnostic(section, "malformed hellparty entry"))
return out
}
obj.HellParty = append(obj.HellParty, HellPartyEntry{
GroupID: tokens[i].Int,
Rate: tokens[i+1].Int,
Order: tokens[i+2].Int,
})
i += 3
}
continue
}
if i+5 >= len(tokens) || !isText(tokens[i]) || !areInts(tokens[i+1:i+6]) {
*diagnostics = append(*diagnostics, diagnostic(section, "malformed special object spawn"))
return out
}
obj.Spawns = append(obj.Spawns, SpecialObjectSpawn{
Kind: tokens[i].Value, Code: tokens[i+1].Int, Level: tokens[i+2].Int,
Params: [3]int64{tokens[i+3].Int, tokens[i+4].Int, tokens[i+5].Int},
})
i += 6
}
out = append(out, obj)
}
return out
}
func isHellPartyOpen(token dnfpvf.Token) bool {
return isText(token) && strings.EqualFold(strings.TrimSpace(token.Value), "[hellparty]")
}
func isHellPartyClose(token dnfpvf.Token) bool {
return isText(token) && strings.EqualFold(strings.TrimSpace(token.Value), "[/hellparty]")
}
// Some story maps encode every special-object header as five integers on its
// own line, with the fifth integer being a zero child count. When four such
// rows are flattened, the token count is also divisible by four; line-aware
// recognition must therefore run before the legacy four-field flat grammar.
func parseZeroSpawnSpecialPassiveObjectRows(tokens []dnfpvf.Token) ([]SpecialPassiveObject, bool) {
if len(tokens) == 0 {
return nil, false
}
var out []SpecialPassiveObject
for start := 0; start < len(tokens); {
line := tokens[start].Line
end := start + 1
for end < len(tokens) && tokens[end].Line == line {
end++
}
row := tokens[start:end]
if len(row) != 5 || !areInts(row) || row[4].Int != 0 {
return nil, false
}
out = append(out, SpecialPassiveObject{PassiveObject: PassiveObject{
ObjectID: row[0].Int,
X: row[1].Int,
Y: row[2].Int,
Flags: row[3].Int,
}})
start = end
}
return out, true
}
func parseMonsters(section RawSection, diagnostics *[]Diagnostic) []MonsterSpawn {
tokens := values(section)
var out []MonsterSpawn
for i := 0; i < len(tokens); {
if len(tokens)-i < 10 {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("monster section has %d trailing tokens at token %d", len(tokens)-i, i)))
break
}
if !areInts(tokens[i:i+8]) || !isText(tokens[i+8]) || !isText(tokens[i+9]) {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed monster record at token %d", i)))
break
}
spawn := MonsterSpawn{
MonsterID: tokens[i].Int, Level: tokens[i+1].Int, AutoLevel: tokens[i+2].Int,
Position: Point3{X: tokens[i+3].Int, Y: tokens[i+4].Int, Z: tokens[i+5].Int},
RandomDropCount: tokens[i+6].Int, FixedDropCount: tokens[i+7].Int,
Placement: tokens[i+8].Value, Rank: tokens[i+9].Value,
}
i += 10
if i < len(tokens) && isMonsterSuffixMarker(tokens[i]) {
spawn.SuffixMarker = tokens[i].Value
i++
}
out = append(out, spawn)
}
return out
}
func isMonsterSuffixMarker(token dnfpvf.Token) bool {
return token.Kind == dnfpvf.TokenString && token.Value == "[boss]"
}
func parseNPCs(section RawSection, diagnostics *[]Diagnostic) []NPCSpawn {
tokens := values(section)
var out []NPCSpawn
for i := 0; i < len(tokens); {
if i+4 < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt && isText(tokens[i+1]) && areInts(tokens[i+2:i+5]) {
out = append(out, NPCSpawn{
NPCID: tokens[i].Int, Direction: tokens[i+1].Value,
Position: Point3{X: tokens[i+2].Int, Y: tokens[i+3].Int, Z: tokens[i+4].Int},
})
i += 5
continue
}
if i+3 < len(tokens) && areInts(tokens[i:i+4]) {
out = append(out, NPCSpawn{NPCID: tokens[i].Int, Position: Point3{X: tokens[i+1].Int, Y: tokens[i+2].Int, Z: tokens[i+3].Int}})
i += 4
continue
}
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed npc record at token %d", i)))
break
}
return out
}
func parseAICharacters(section RawSection, diagnostics *[]Diagnostic) []AICharacter {
tokens := values(section)
var out []AICharacter
for i := 0; i < len(tokens); {
if i+5 >= len(tokens) || !areInts(tokens[i:i+4]) || !isText(tokens[i+4]) || !isText(tokens[i+5]) {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed ai character at token %d", i)))
break
}
entry := AICharacter{
Code: tokens[i].Int, Position: Point{X: tokens[i+1].Int, Y: tokens[i+2].Int},
Direction: tokens[i+3].Int, Faction: tokens[i+4].Value, AIType: tokens[i+5].Value,
}
i += 6
for len(entry.Params) < 2 && i < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt {
entry.Params = append(entry.Params, tokens[i].Int)
i++
}
out = append(out, entry)
}
return out
}
func parseMovableAreas(section RawSection, diagnostics *[]Diagnostic) []MovableArea {
parsed, valid := ints(section)
if !valid || len(parsed)%6 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected six-integer movable-area records"))
}
out := make([]MovableArea, 0, len(parsed)/6)
for i := 0; i+5 < len(parsed); i += 6 {
out = append(out, MovableArea{
X: parsed[i], Y: parsed[i+1], Width: parsed[i+2], Height: parsed[i+3],
Params: [2]int64{parsed[i+4], parsed[i+5]},
})
}
return out
}
func parseDungeonMovableAreas(section RawSection, diagnostics *[]Diagnostic) []MovableArea {
parsed, valid := ints(section)
if !valid || len(parsed)%4 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected four-integer dungeon movable-area records"))
}
out := make([]MovableArea, 0, len(parsed)/4)
for i := 0; i+3 < len(parsed); i += 4 {
out = append(out, MovableArea{X: parsed[i], Y: parsed[i+1], Width: parsed[i+2], Height: parsed[i+3]})
}
return out
}
func attachHellParty(sections []RawSection, result *Map) {
for _, section := range sections {
if sectionKey(section.Name) != "hellparty" || len(result.SpecialPassiveObjects) == 0 {
continue
}
parsed, valid := ints(section)
if !valid || len(parsed)%3 != 0 {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected group/rate/order records"))
}
last := &result.SpecialPassiveObjects[len(result.SpecialPassiveObjects)-1]
for i := 0; i+2 < len(parsed); i += 3 {
last.HellParty = append(last.HellParty, HellPartyEntry{GroupID: parsed[i], Rate: parsed[i+1], Order: parsed[i+2]})
}
}
}
func buildPortals(positions []Point, objects []int64) []Portal {
count := max(len(positions), len(objects))
out := make([]Portal, count)
for i := 0; i < count; i++ {
out[i].Slot = i
if i < len(positions) {
position := positions[i]
out[i].Position = &position
}
if i < len(objects) {
out[i].ObjectID = OptionalInt{Value: objects[i], Set: true}
}
}
return out
}
func setMapFlag(flags *MapFlags, key string) {
switch key {
case "block use stackable item":
flags.BlockUseStackableItem = true
case "block use active skill":
flags.BlockUseActiveSkill = true
case "visible on dungeon clear":
flags.VisibleOnDungeonClear = true
case "loop y axis":
flags.LoopYAxis = true
case "all dead case passable":
flags.AllDeadCasePassable = true
case "disable item escape stuck":
flags.DisableItemEscapeStuck = true
case "disable character escape stuck":
flags.DisableCharacterEscapeStuck = true
case "cannot use coin map":
flags.CannotUseCoinMap = true
case "no revival timer limit":
flags.NoRevivalTimerLimit = true
case "ignore diehard":
flags.IgnoreDiehard = true
case "disable rebirth":
flags.DisableRebirth = true
case "preserve player corpse":
flags.PreservePlayerCorpse = true
case "cannot use resolution change zoom":
flags.CannotUseResolutionChangeZoom = true
case "center fixed camera":
flags.CenterFixedCamera = true
case "force draw pattern":
flags.ForceDrawPattern = true
case "is revival":
flags.IsRevival = true
case "is moive end", "is movie end":
flags.IsMovieEnd = true
case "quest start map":
flags.QuestStartMap = true
case "hide monster":
flags.HideMonster = true
case "show dust":
flags.ShowDust = true
}
}
func isText(token dnfpvf.Token) bool {
return token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent
}
func areInts(tokens []dnfpvf.Token) bool {
for _, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return false
}
}
return true
}
func cloneRawSection(section RawSection) RawSection {
section.Tokens = append([]dnfpvf.Token(nil), section.Tokens...)
section.Scope = append([]string(nil), section.Scope...)
return section
}
@@ -0,0 +1,351 @@
package worldmap
import (
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestParseMapKnownFieldsRepeatedSectionsAndRawPreservation(t *testing.T) {
doc := parseTestDocument(t, "map/test.map", `
[map name]
`+"`training town`"+`
[dungeon]
7001 7002
[tile]
`+"`tile/a.til`"+`
[/tile]
[tile]
`+"`tile/b.til`"+`
[/tile]
[background animation]
[ani info]
[filename]
`+"`animation/far.ani`"+`
[layer]
`+"`[distantback]`"+`
[order]
`+"`[below]`"+`
[/ani info]
[/background animation]
[pathgate pos]
10 20 30 40
[pathgate object]
5001
[npc]
1000 `+"`[left]`"+` -62 -125 0
[npc]
1001 `+"`[right]`"+` 50 60 1
[animation]
`+"`animation/tree.ani` `[normal]`"+` 1 2 3
[passive object]
5 100 200 7
[monster]
3001 20 0 100 200 0 1 2 `+"`[fixed]` `[boss]`"+`
[monster team]
100
[event monster position]
7 8 9
[ai character]
4001 11 12 1 `+"`[neutral]` `[boss]`"+` 9 10
[town movable area]
1 2 300 400 8 1
[block use active skill]
[is movie end]
[map animation]
`+"`future nested grammar`"+` 3
[future map feature]
`+"`kept verbatim`"+` 77
`)
parsed := ParseMap(91, "map/test.map", doc)
if parsed.ID != 91 || parsed.Name != "training town" || !parsed.DungeonID.Set || parsed.DungeonID.Value != 7001 {
t.Fatalf("unexpected map identity: %+v", parsed)
}
if len(parsed.DungeonIDs) != 2 || parsed.DungeonIDs[0] != 7001 || parsed.DungeonIDs[1] != 7002 {
t.Fatalf("map dungeon ownership = %v", parsed.DungeonIDs)
}
if len(parsed.Tiles) != 2 || parsed.Tiles[0] != "tile/a.til" || parsed.Tiles[1] != "tile/b.til" {
t.Fatalf("repeated tiles were not accumulated: %+v", parsed.Tiles)
}
if len(parsed.BackgroundAnimations) != 1 || parsed.BackgroundAnimations[0].Filename != "animation/far.ani" {
t.Fatalf("unexpected background animation: %+v", parsed.BackgroundAnimations)
}
if len(parsed.Portals) != 2 || parsed.Portals[0].Position.X != 10 || !parsed.Portals[0].ObjectID.Set || parsed.Portals[1].ObjectID.Set {
t.Fatalf("unexpected portal graph: %+v", parsed.Portals)
}
if len(parsed.NPCs) != 2 || parsed.NPCs[0].Direction != "[left]" || parsed.NPCs[1].Position.Z != 1 {
t.Fatalf("unexpected npc nodes: %+v", parsed.NPCs)
}
if len(parsed.AnimationObjects) != 1 || len(parsed.PassiveObjects) != 1 || len(parsed.Monsters) != 1 || parsed.Monsters[0].Rank != "[boss]" {
t.Fatalf("unexpected object graph: animation=%+v passive=%+v monsters=%+v", parsed.AnimationObjects, parsed.PassiveObjects, parsed.Monsters)
}
if len(parsed.AICharacters) != 1 || len(parsed.AICharacters[0].Params) != 2 || len(parsed.TownMovableAreas) != 1 {
t.Fatalf("unexpected apc/town data: apc=%+v areas=%+v", parsed.AICharacters, parsed.TownMovableAreas)
}
if len(parsed.MonsterTeam) != 1 || parsed.MonsterTeam[0] != 100 {
t.Fatalf("unexpected monster teams: %v", parsed.MonsterTeam)
}
if !parsed.Flags.BlockUseActiveSkill || !parsed.Flags.IsMovieEnd {
t.Fatalf("expected map flags: %+v", parsed.Flags)
}
if len(parsed.OpaqueSections) != 1 || sectionKey(parsed.OpaqueSections[0].Name) != "map animation" {
t.Fatalf("opaque section not retained: %+v", parsed.OpaqueSections)
}
if len(parsed.UnknownSections) != 1 || parsed.UnknownSections[0].Tokens[0].Raw != "`kept verbatim`" || parsed.UnknownSections[0].Tokens[1].Int != 77 {
t.Fatalf("unknown section not retained: %+v", parsed.UnknownSections)
}
var tileOccurrences []int
for _, section := range parsed.SourceSections {
if sectionKey(section.Name) == "tile" {
tileOccurrences = append(tileOccurrences, section.Occurrence)
}
}
if len(tileOccurrences) != 2 || tileOccurrences[0] != 1 || tileOccurrences[1] != 2 {
t.Fatalf("section occurrences = %+v", tileOccurrences)
}
}
func TestParseMapMalformedValuesKeepSourceAndReportDiagnostics(t *testing.T) {
doc := parseTestDocument(t, "map/bad.map", `
[dungeon]
`+"`not-an-id`"+`
[pathgate pos]
1 2 3
[npc]
1000 `+"`[left]`"+` 10
[monster]
1 2 3 4 5 6 7 8 `+"`[fixed]`"+`
[unknown malformed]
{ `+"`payload`"+` = 4 }
`)
parsed := ParseMap(1, "map/bad.map", doc)
if parsed.DungeonID.Set {
t.Fatalf("malformed dungeon id became valid: %+v", parsed.DungeonID)
}
if len(parsed.PathgatePositions) != 1 || len(parsed.NPCs) != 0 || len(parsed.Monsters) != 0 {
t.Fatalf("malformed records crossed boundaries: portals=%+v npcs=%+v monsters=%+v", parsed.PathgatePositions, parsed.NPCs, parsed.Monsters)
}
if len(parsed.Diagnostics) < 4 {
t.Fatalf("missing diagnostics: %+v", parsed.Diagnostics)
}
if len(parsed.SourceSections) != 5 || len(parsed.UnknownSections) != 1 || len(parsed.UnknownSections[0].Tokens) != 5 {
t.Fatalf("raw malformed source was lost: source=%+v unknown=%+v", parsed.SourceSections, parsed.UnknownSections)
}
}
func TestParseMapBloodAltarTables(t *testing.T) {
doc := parseTestDocument(t, "map/altar.map", `
[blood monster]
61010 0 712 216 0 13000 1500 1 -1 8
[/blood monster]
[blood phase time]
0 0 6000 1.10 0 4
[/blood phase time]
[ultimate monster]
56017 0 440 222 0 5000 4000 1 0 1
59011 1 730 260 800 7000 2000 2 1 3
[/ultimate monster]
[ultimate phase time]
0 0 5000 1 0 0 `+"`A`"+`
1 1 2500 1.80 2 5 `+"`B`"+`
[/ultimate phase time]
`)
parsed := ParseMap(17030, "map/altar.map", doc)
if len(parsed.BloodMonsters) != 1 || len(parsed.BloodPhases) != 1 ||
len(parsed.UltimateMonsters) != 2 || len(parsed.UltimatePhases) != 2 {
t.Fatalf("altar tables = blood monsters=%+v phases=%+v ultimate monsters=%+v phases=%+v diagnostics=%+v",
parsed.BloodMonsters, parsed.BloodPhases, parsed.UltimateMonsters, parsed.UltimatePhases, parsed.Diagnostics)
}
bloodMonster := parsed.BloodMonsters[0]
if bloodMonster.MonsterCode != 61010 || bloodMonster.Position != (Point3{X: 712, Y: 216, Z: 0}) ||
bloodMonster.DurationMilliseconds != 13000 || bloodMonster.SpawnIntervalMilliseconds != 1500 ||
bloodMonster.BaseSpawnCount != 1 || bloodMonster.SpawnCountIncrement != -1 || bloodMonster.BatchCount != 8 {
t.Fatalf("blood monster = %+v", bloodMonster)
}
if phase := parsed.BloodPhases[0]; phase.Round != 0 || phase.MonsterTemplateIndex != 0 ||
phase.DelayMilliseconds != 6000 || phase.Scale != 1.10 || phase.ConcurrentPhaseCount != 4 || phase.Difficulty != "" {
t.Fatalf("blood phase = %+v", phase)
}
if phase := parsed.UltimatePhases[0]; phase.Scale != 1 || phase.Difficulty != BloodAltarDifficultyA {
t.Fatalf("ultimate integer-scale phase = %+v", phase)
}
if phase := parsed.UltimatePhases[1]; phase.MonsterTemplateIndex != 1 || phase.Scale != 1.80 ||
phase.Flag != 2 || phase.ConcurrentPhaseCount != 5 || phase.Difficulty != BloodAltarDifficultyB {
t.Fatalf("ultimate float-scale phase = %+v", phase)
}
for _, section := range parsed.OpaqueSections {
if isBloodAltarMapSection(section.Name) {
t.Fatalf("typed altar section remained opaque: %+v", section)
}
}
}
func TestParseMapBloodAltarRejectsMalformedTables(t *testing.T) {
validMonster := "56017 0 440 222 0 5000 4000 1 0 1"
tests := []struct {
name string
body string
ultimate bool
}{
{name: "monster width", body: "[blood monster]\n56017 0 440 222 0 5000 4000 1 0\n[/blood monster]"},
{name: "monster coordinate", body: "[blood monster]\n56017 0 65536 222 0 5000 4000 1 0 1\n[/blood monster]"},
{name: "phase width", body: "[blood monster]\n" + validMonster + "\n[/blood monster]\n[blood phase time]\n0 0 5000 1 0\n[/blood phase time]"},
{name: "phase index", body: "[blood monster]\n" + validMonster + "\n[/blood monster]\n[blood phase time]\n0 1 5000 1 0 0\n[/blood phase time]"},
{name: "phase scale", body: "[blood monster]\n" + validMonster + "\n[/blood monster]\n[blood phase time]\n0 0 5000 0 0 0\n[/blood phase time]"},
{name: "phase concurrency", body: "[blood monster]\n" + validMonster + "\n[/blood monster]\n[blood phase time]\n0 0 5000 1 0 11\n[/blood phase time]"},
{name: "ultimate difficulty C", ultimate: true, body: "[ultimate monster]\n" + validMonster + "\n[/ultimate monster]\n[ultimate phase time]\n0 0 5000 1 0 0 `C`\n[/ultimate phase time]"},
{name: "ultimate numeric difficulty", ultimate: true, body: "[ultimate monster]\n" + validMonster + "\n[/ultimate monster]\n[ultimate phase time]\n0 0 5000 1 0 0 1\n[/ultimate phase time]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed := ParseMap(1, "map/bad-altar.map", parseTestDocument(t, "map/bad-altar.map", tt.body))
if len(parsed.Diagnostics) == 0 {
t.Fatalf("malformed altar table has no diagnostics: %+v", parsed)
}
if tt.ultimate {
if len(parsed.UltimatePhases) != 0 {
t.Fatalf("malformed ultimate phase table accepted: %+v", parsed.UltimatePhases)
}
} else if len(parsed.BloodPhases) != 0 || (tt.name[:7] == "monster" && len(parsed.BloodMonsters) != 0) {
t.Fatalf("malformed blood table accepted: monsters=%+v phases=%+v", parsed.BloodMonsters, parsed.BloodPhases)
}
})
}
}
func TestParseMapLocalPVFExtensionsAndNestedSummons(t *testing.T) {
doc := parseTestDocument(t, "map/local.map", `
[tile files]
0 `+"`Tile/Act1.til`"+` 1 `+"`Tile/Common.til`"+`
[tile map]
0 1 1 0
[dungeon movable area]
13 122 1110 360
[random start map]
1
[pathgate recognize range]
240
[summon]
[summon key]
7
[position]
1500 160 0 0
[type]
`+"`gold`"+`
[itemcount]
2
[index]
19000
[life time]
-1
[info]
[distance]
10000000 1500 0
[future summon field]
`+"`retained`"+`
[/info]
[/summon]
[future top level]
9 `+"`typed`"+`
`)
parsed := ParseMap(99, "map/local.map", doc)
if len(parsed.TileFiles) != 2 || parsed.TileFiles[1].Index != 1 || parsed.TileFiles[1].Path != "Tile/Common.til" {
t.Fatalf("tile files = %+v", parsed.TileFiles)
}
if len(parsed.TileMapRows) != 1 || len(parsed.TileMapRows[0]) != 4 || parsed.TileMapRows[0][2] != 1 {
t.Fatalf("tile map rows = %+v", parsed.TileMapRows)
}
if len(parsed.DungeonMovableAreas) != 1 || parsed.DungeonMovableAreas[0].Width != 1110 {
t.Fatalf("dungeon movable areas = %+v", parsed.DungeonMovableAreas)
}
if !parsed.RandomStartMap.Set || parsed.RandomStartMap.Value != 1 || !parsed.PathgateRecognizeRange.Set || parsed.PathgateRecognizeRange.Value != 240 {
t.Fatalf("local scalar extensions = random=%+v range=%+v", parsed.RandomStartMap, parsed.PathgateRecognizeRange)
}
if len(parsed.Summons) != 1 {
t.Fatalf("summons = %+v", parsed.Summons)
}
summon := parsed.Summons[0]
if !summon.Key.Set || summon.Key.Value != 7 || summon.Type != "gold" || !summon.Index.Set || summon.Index.Value != 19000 || len(summon.Info) < 2 {
t.Fatalf("summon = %+v", summon)
}
if len(summon.UnknownSections) != 1 || len(summon.UnknownSections[0].Scope) != 2 || summon.UnknownSections[0].Scope[0] != "summon" || summon.UnknownSections[0].Scope[1] != "info" {
t.Fatalf("summon unknown scope = %+v", summon.UnknownSections)
}
if len(parsed.UnknownSections) != 1 || len(parsed.Extensions) != 1 || parsed.Extensions[0].Kind != SectionMixed {
t.Fatalf("top-level extension = unknown=%+v typed=%+v", parsed.UnknownSections, parsed.Extensions)
}
}
func TestParseSpecialPassiveObjectFlatAndExtendedRecords(t *testing.T) {
doc := parseTestDocument(t, "map/special.map", `
[special passive object]
10 1 2 3 11 4 5 6
[special passive object]
20 7 8 9 1 `+"`[monster]`"+` 3001 50 1 2 3
[hellparty]
4 100 0
[/hellparty]
`)
parsed := ParseMap(2, "map/special.map", doc)
if len(parsed.SpecialPassiveObjects) != 3 {
t.Fatalf("special objects = %+v", parsed.SpecialPassiveObjects)
}
if parsed.SpecialPassiveObjects[0].ObjectID != 10 || parsed.SpecialPassiveObjects[1].ObjectID != 11 {
t.Fatalf("flat objects = %+v", parsed.SpecialPassiveObjects)
}
last := parsed.SpecialPassiveObjects[2]
if len(last.Spawns) != 1 || last.Spawns[0].Code != 3001 || len(last.HellParty) != 1 {
t.Fatalf("extended object = %+v", last)
}
}
func TestParseSpecialPassiveObjectPrefersFiveFieldZeroSpawnRows(t *testing.T) {
doc := parseTestDocument(t, "map/special-zero-spawn.map", `
[special passive object]
1 10 20 30 0
2 11 21 31 0
3 12 22 32 0
4 13 23 33 0
`)
parsed := ParseMap(3, "map/special-zero-spawn.map", doc)
if len(parsed.SpecialPassiveObjects) != 4 {
t.Fatalf("special objects=%+v want four five-field headers", parsed.SpecialPassiveObjects)
}
for index, object := range parsed.SpecialPassiveObjects {
if object.ObjectID != int64(index+1) || object.Flags != int64(30+index) || len(object.Spawns) != 0 {
t.Fatalf("special object[%d]=%+v", index, object)
}
}
}
func TestParseSpecialPassiveObjectWithInlineHellParty(t *testing.T) {
doc := parseTestDocument(t, "map/hell-inline.map", "[special passive object]\n30528 594 301 0 1 `[hellparty]` 125 25 1 126 25 1 `[/hellparty]`\n")
parsed := ParseMap(4, "map/hell-inline.map", doc)
if len(parsed.Diagnostics) != 0 {
t.Fatalf("diagnostics = %+v", parsed.Diagnostics)
}
if len(parsed.SpecialPassiveObjects) != 1 {
t.Fatalf("special objects = %+v", parsed.SpecialPassiveObjects)
}
object := parsed.SpecialPassiveObjects[0]
if object.ObjectID != 30528 || object.X != 594 || object.Y != 301 || len(object.Spawns) != 0 || len(object.HellParty) != 2 {
t.Fatalf("inline Hell Party object = %+v", object)
}
if object.HellParty[0] != (HellPartyEntry{GroupID: 125, Rate: 25, Order: 1}) ||
object.HellParty[1] != (HellPartyEntry{GroupID: 126, Rate: 25, Order: 1}) {
t.Fatalf("inline Hell Party entries = %+v", object.HellParty)
}
}
func parseTestDocument(t *testing.T, docPath, text string) *dnfpvf.Document {
t.Helper()
doc, err := dnfpvf.Parse(docPath, text)
if err != nil {
t.Fatalf("parse document: %v", err)
}
return doc
}
@@ -0,0 +1,48 @@
package worldmap
import (
"os"
"strings"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFThiefTutorialMonsterBossSuffixPreservesThirdRecord(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the real Script.pvf monster-suffix regression")
}
const mapPath = "map/Cataclysm/NewTutorial/Thief_F/310004(3,0).map"
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
text, err := archive.ReadText(mapPath)
if err != nil {
t.Fatalf("read %s: %v", mapPath, err)
}
document, err := ParseDocument(mapPath, text)
if err != nil {
t.Fatalf("parse %s: %v", mapPath, err)
}
parsed := ParseMap(310004, mapPath, document)
want := []int64{107004985, 107004981, 107004986}
if len(parsed.Monsters) != len(want) {
t.Fatalf("real tutorial monsters=%+v want_ids=%v diagnostics=%+v", parsed.Monsters, want, parsed.Diagnostics)
}
for index, monster := range parsed.Monsters {
if monster.MonsterID != want[index] {
t.Fatalf("real tutorial monster[%d]=%+v want_id=%d", index, monster, want[index])
}
}
if parsed.Monsters[1].SuffixMarker != "[boss]" {
t.Fatalf("real tutorial boss suffix was not preserved as syntax: %+v", parsed.Monsters[1])
}
for _, entry := range parsed.Diagnostics {
if sectionKey(entry.Section) == "monster" &&
(strings.Contains(entry.Message, "malformed monster") || strings.Contains(entry.Message, "trailing tokens")) {
t.Fatalf("real tutorial boss suffix rejected: %+v", parsed.Diagnostics)
}
}
}
@@ -0,0 +1,72 @@
package worldmap
import (
"strings"
"testing"
)
func TestParseMapMonsterBossSuffixDoesNotHideFollowingRecord(t *testing.T) {
doc := parseTestDocument(t, "map/monster-suffix.map", `
[monster]
1001 1 0 38 273 0 1 1 `+"`[fixed]` `[normal]`"+` 1002 1 0 1224 -263 0 1 1 `+"`[fixed]` `[dummy]` `[boss]`"+` 1003 1 0 1299 -314 0 1 1 `+"`[fixed]` `[normal]`"+`
[/monster]
`)
parsed := ParseMap(310004, "map/monster-suffix.map", doc)
if len(parsed.Monsters) != 3 || parsed.Monsters[0].MonsterID != 1001 ||
parsed.Monsters[1].MonsterID != 1002 || parsed.Monsters[2].MonsterID != 1003 {
t.Fatalf("monster suffix crossed record boundary: monsters=%+v diagnostics=%+v", parsed.Monsters, parsed.Diagnostics)
}
if parsed.Monsters[1].SuffixMarker != "[boss]" {
t.Fatalf("boss suffix was not preserved as syntax: %+v", parsed.Monsters[1])
}
for _, entry := range parsed.Diagnostics {
if sectionKey(entry.Section) == "monster" &&
(strings.Contains(entry.Message, "malformed monster") || strings.Contains(entry.Message, "trailing tokens")) {
t.Fatalf("proved boss suffix was rejected: %+v", parsed.Diagnostics)
}
}
}
func TestParseMapMonsterBossSuffixAtSectionEndIsNotReportedAsTrailing(t *testing.T) {
doc := parseTestDocument(t, "map/monster-suffix-end.map", `
[monster]
1001 1 0 38 273 0 1 1 `+"`[fixed]` `[dummy]` `[boss]`"+`
[/monster]
`)
parsed := ParseMap(76031, "map/monster-suffix-end.map", doc)
if len(parsed.Monsters) != 1 || parsed.Monsters[0].MonsterID != 1001 {
t.Fatalf("monster suffix changed core record: monsters=%+v diagnostics=%+v", parsed.Monsters, parsed.Diagnostics)
}
if parsed.Monsters[0].SuffixMarker != "[boss]" {
t.Fatalf("terminal boss suffix was not preserved as syntax: %+v", parsed.Monsters[0])
}
for _, entry := range parsed.Diagnostics {
if sectionKey(entry.Section) == "monster" {
t.Fatalf("proved terminal boss suffix was rejected: %+v", parsed.Diagnostics)
}
}
}
func TestParseMapUnknownMonsterSuffixRemainsFailClosed(t *testing.T) {
doc := parseTestDocument(t, "map/monster-unknown-suffix.map", `
[monster]
1001 1 0 38 273 0 1 1 `+"`[fixed]` `[dummy]` `[unknown suffix]`"+` 1002 1 0 1299 -314 0 1 1 `+"`[fixed]` `[normal]`"+`
[/monster]
`)
parsed := ParseMap(1, "map/monster-unknown-suffix.map", doc)
if len(parsed.Monsters) != 1 || parsed.Monsters[0].MonsterID != 1001 {
t.Fatalf("unknown suffix was guessed or corrupted core record: %+v", parsed.Monsters)
}
if parsed.Monsters[0].SuffixMarker != "" {
t.Fatalf("unknown suffix was promoted to a recognized marker: %+v", parsed.Monsters[0])
}
foundMalformed := false
for _, entry := range parsed.Diagnostics {
if sectionKey(entry.Section) == "monster" && strings.Contains(entry.Message, "malformed monster") {
foundMalformed = true
}
}
if !foundMalformed {
t.Fatalf("unknown suffix did not remain fail-closed: %+v", parsed.Diagnostics)
}
}
@@ -0,0 +1,141 @@
package worldmap
import (
"fmt"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func ParseArea(id int64, areaPath string, doc *dnfpvf.Document) Area {
result := Area{ID: id, Path: areaPath}
sections, diagnostics := rawSections(doc)
result.SourceSections = sections
result.Diagnostics = append(result.Diagnostics, diagnostics...)
for _, section := range sections {
key := sectionKey(section.Name)
if isClosingSection(key) {
continue
}
if _, ok := knownAreaSections[key]; !ok {
result.UnknownSections = append(result.UnknownSections, cloneRawSection(section))
result.Extensions = append(result.Extensions, structureExtension(section))
continue
}
switch key {
case "map image":
if path, ok := firstText(section); ok {
result.MapImage.Path = path
} else {
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected map image path"))
}
parsed, _ := ints(section)
result.MapImage.Params = append(result.MapImage.Params, parsed...)
case "ui path":
setText(&result.UIPath, section, &result.Diagnostics)
case "name":
setText(&result.Name, section, &result.Diagnostics)
case "dungeon":
result.Dungeons = append(result.Dungeons, parseDungeonEntries(section, &result.Diagnostics)...)
case "in progress":
result.Dungeons = append(result.Dungeons, parseInProgressDungeonEntries(section, &result.Diagnostics)...)
case "hell dungeon":
setOptionalInt(&result.HellDungeon, section, &result.Diagnostics)
case "hell quest":
result.HellQuestIDs = appendInts(result.HellQuestIDs, section, &result.Diagnostics)
case "hell freepass item":
result.HellFreePassItems = append(result.HellFreePassItems, parseTicketItems(section, &result.Diagnostics)...)
case "inferno freepass item":
result.InfernoFreePassItems = append(result.InfernoFreePassItems, parseTicketItems(section, &result.Diagnostics)...)
case "particularhell freepass item":
result.ParticularHellFreePassItems = append(result.ParticularHellFreePassItems, parseTicketItems(section, &result.Diagnostics)...)
case "item condition":
result.ItemConditions = appendInts(result.ItemConditions, section, &result.Diagnostics)
}
}
return result
}
func parseInProgressDungeonEntries(section RawSection, diagnostics *[]Diagnostic) []DungeonEntry {
parsed, valid := ints(section)
if !valid || len(parsed)%2 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected in-progress quest/dungeon integer pairs"))
}
out := make([]DungeonEntry, 0, len(parsed)/2)
for index := 0; index+1 < len(parsed); index += 2 {
out = append(out, DungeonEntry{QuestID: parsed[index], DungeonID: parsed[index+1], InProgressOnly: true})
}
return out
}
func parseDungeonEntries(section RawSection, diagnostics *[]Diagnostic) []DungeonEntry {
tokens := values(section)
var out []DungeonEntry
pendingInProgress := false
for i := 0; i < len(tokens); {
if marker, width := inProgressMarker(tokens, i); marker {
pendingInProgress = true
i += width
continue
}
if tokens[i].Kind != dnfpvf.TokenInt {
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("unexpected dungeon token %q", tokens[i].Raw)))
i++
continue
}
first := tokens[i].Int
i++
if pendingInProgress {
if i >= len(tokens) || tokens[i].Kind != dnfpvf.TokenInt {
*diagnostics = append(*diagnostics, diagnostic(section, "in-progress quest is missing dungeon id"))
break
}
out = append(out, DungeonEntry{DungeonID: tokens[i].Int, QuestID: first, InProgressOnly: true})
i++
pendingInProgress = false
continue
}
entry := DungeonEntry{DungeonID: first, QuestID: -1}
if marker, width := inProgressMarker(tokens, i); marker {
entry.InProgressOnly = true
i += width
}
if i < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt {
entry.QuestID = tokens[i].Int
i++
}
out = append(out, entry)
}
if pendingInProgress {
*diagnostics = append(*diagnostics, diagnostic(section, "in-progress marker has no quest/dungeon pair"))
}
return out
}
func inProgressMarker(tokens []dnfpvf.Token, index int) (bool, int) {
if index < 0 || index >= len(tokens) || !isText(tokens[index]) {
return false, 0
}
value := sectionKey(tokens[index].Value)
if value == "[in progress]" || value == "in progress" {
return true, 1
}
if (value == "[in" || value == "in") && index+1 < len(tokens) && isText(tokens[index+1]) {
next := sectionKey(tokens[index+1].Value)
if next == "progress]" || next == "progress" {
return true, 2
}
}
return false, 0
}
func parseTicketItems(section RawSection, diagnostics *[]Diagnostic) []TicketItem {
parsed, valid := ints(section)
if !valid || len(parsed)%2 != 0 {
*diagnostics = append(*diagnostics, diagnostic(section, "expected item/count integer pairs"))
}
out := make([]TicketItem, 0, len(parsed)/2)
for i := 0; i+1 < len(parsed); i += 2 {
out = append(out, TicketItem{ItemID: parsed[i], Count: parsed[i+1]})
}
return out
}
@@ -0,0 +1,80 @@
package worldmap
import "testing"
func TestParseAreaKnownRepeatedAndUnknownSections(t *testing.T) {
doc := parseTestDocument(t, "worldmap/test.wdm", `
[map image]
`+"`WorldMap/Test.img`"+` 3
[ui path]
`+"`WorldMap/UI/Test.xui`"+`
[name]
`+"`Test Area`"+`
[dungeon]
1 -1 2 20
[/dungeon]
[dungeon]
`+"`[in progress]`"+` 900 3
[/dungeon]
[in progress]
901 4 902 5
[hell dungeon]
1
[hell quest]
100 101
[/hell quest]
[hell quest]
102
[/hell quest]
[hell freepass item]
500 1 501 2
[/hell freepass item]
[inferno freepass item]
600 1 601 2
[/inferno freepass item]
[particularhell freepass item]
700 1 701 2
[/particularhell freepass item]
[item condition]
800
[/item condition]
[future area rule]
`+"`preserve me`"+`
`)
area := ParseArea(8, "worldmap/test.wdm", doc)
if area.Name != "Test Area" || area.MapImage.Path != "WorldMap/Test.img" || len(area.MapImage.Params) != 1 || area.UIPath == "" {
t.Fatalf("unexpected area metadata: %+v", area)
}
if len(area.Dungeons) != 5 || area.Dungeons[1].QuestID != 20 || !area.Dungeons[2].InProgressOnly || area.Dungeons[2].DungeonID != 3 || area.Dungeons[2].QuestID != 900 || area.Dungeons[4].DungeonID != 5 || area.Dungeons[4].QuestID != 902 {
t.Fatalf("unexpected dungeon graph: %+v", area.Dungeons)
}
if !area.HellDungeon.Set || area.HellDungeon.Value != 1 || len(area.HellQuestIDs) != 3 || len(area.HellFreePassItems) != 2 ||
len(area.InfernoFreePassItems) != 2 || len(area.ParticularHellFreePassItems) != 2 ||
area.InfernoFreePassItems[0] != (TicketItem{ItemID: 600, Count: 1}) ||
area.ParticularHellFreePassItems[1] != (TicketItem{ItemID: 701, Count: 2}) ||
len(area.ItemConditions) != 1 || area.ItemConditions[0] != 800 {
t.Fatalf("unexpected hell data: %+v", area)
}
if len(area.UnknownSections) != 1 || len(area.Extensions) != 1 || area.UnknownSections[0].Tokens[0].Value != "preserve me" {
t.Fatalf("unknown area section was lost: %+v", area.UnknownSections)
}
}
func TestParseAreaMalformedPairsReportDiagnostics(t *testing.T) {
doc := parseTestDocument(t, "worldmap/bad.wdm", `
[dungeon]
`+"`[in progress]`"+` 99
[hell freepass item]
1 2 3
[inferno freepass item]
4 5 6
[particularhell freepass item]
7 8 9
`)
area := ParseArea(9, "worldmap/bad.wdm", doc)
if len(area.Dungeons) != 0 || len(area.HellFreePassItems) != 1 ||
len(area.InfernoFreePassItems) != 1 || len(area.ParticularHellFreePassItems) != 1 || len(area.Diagnostics) < 4 {
t.Fatalf("malformed area parse = %+v", area)
}
}
@@ -0,0 +1,143 @@
package worldmap
import (
"context"
"os"
"path"
"sort"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFUnknownSectionInventory(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatal(err)
}
mapUnknown := make(map[string]int)
areaUnknown := make(map[string]int)
dungeonUnknown := make(map[string]int)
mazeUnknown := make(map[string]int)
for _, value := range table.Maps() {
for _, section := range value.UnknownSections {
key := sectionKey(section.Name)
mapUnknown[key]++
}
}
for _, value := range table.Areas() {
for _, section := range value.UnknownSections {
areaUnknown[sectionKey(section.Name)]++
}
}
for _, value := range table.Dungeons() {
for _, section := range value.UnknownSections {
dungeonUnknown[sectionKey(section.Name)]++
}
for _, maze := range value.Mazes {
for _, section := range maze.UnknownSections {
mazeUnknown[sectionKey(section.Name)]++
}
}
}
t.Logf("map unknown top20: %v", topSectionCounts(mapUnknown, 20))
t.Logf("area unknown top20: %v", topSectionCounts(areaUnknown, 20))
t.Logf("dungeon unknown top20: %v", topSectionCounts(dungeonUnknown, 20))
t.Logf("maze unknown top20: %v", topSectionCounts(mazeUnknown, 20))
}
func TestRealScriptPVFElvengardImportsStorageNPC(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
mapPath := ""
for _, file := range archive.Files() {
if strings.EqualFold(path.Base(file.ArchivePath), "(f)new_seria_room.map") {
mapPath = file.ArchivePath
break
}
}
if mapPath == "" {
var candidates []string
for _, file := range archive.Files() {
value := strings.ToLower(file.ArchivePath + " " + file.Path + " " + file.Name)
if path.Ext(file.ArchivePath) == ".map" && (strings.Contains(value, "seria") || strings.Contains(value, "elvengard")) {
candidates = append(candidates, file.ArchivePath+" | "+file.Path+" | "+file.Name)
if len(candidates) == 12 {
break
}
}
}
t.Fatalf("(F)new_seria_room.map is absent from the runtime PVF; matching entries=%q", candidates)
}
text, err := archive.ReadText(mapPath)
if err != nil {
t.Fatalf("read %s: %v", mapPath, err)
}
doc, err := ParseDocument(mapPath, text)
if err != nil {
t.Fatalf("parse %s: %v", mapPath, err)
}
parsed, err := parseMapWithImports(0, mapPath, doc, func(candidate string) (string, *dnfpvf.Document, error) {
importText, err := archive.ReadText(candidate)
if err != nil {
return "", nil, err
}
importDoc, err := ParseDocument(candidate, importText)
if err != nil {
return "", nil, err
}
return candidate, importDoc, nil
})
if err != nil {
t.Fatalf("resolve imports for %s: %v", mapPath, err)
}
for _, spawn := range parsed.NPCs {
if spawn.NPCID == 22 && spawn.Direction == "[right]" && spawn.Position == (Point3{X: 187, Y: 200, Z: 0}) {
return
}
}
t.Fatalf("Storage.npc (id=22 at 187,200) not found in %s imported NPCs: %+v", mapPath, parsed.NPCs)
}
type sectionCount struct {
Name string
Count int
}
func sortedSectionCounts(values map[string]int) []sectionCount {
out := make([]sectionCount, 0, len(values))
for name, count := range values {
out = append(out, sectionCount{Name: name, Count: count})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Name < out[j].Name
})
return out
}
func topSectionCounts(values map[string]int, limit int) []sectionCount {
out := sortedSectionCounts(values)
if limit >= 0 && len(out) > limit {
out = out[:limit]
}
return out
}
@@ -0,0 +1,975 @@
package worldmap
import (
"errors"
"fmt"
"path"
"regexp"
"strings"
)
var (
ErrResolverTableRequired = errors.New("dnf worldmap resolver table is required")
ErrDungeonNotIndexed = errors.New("dnf dungeon is not indexed")
ErrMazeNotFound = errors.New("dnf dungeon maze is not found")
ErrMapReferenceMissing = errors.New("dnf dungeon map reference is missing")
ErrMapUnresolved = errors.New("dnf dungeon map is unresolved")
ErrMapAmbiguous = errors.New("dnf dungeon map is ambiguous")
ErrMapPoolEmpty = errors.New("dnf dungeon map pool is empty")
ErrMapPoolChoiceInvalid = errors.New("dnf dungeon map pool choice is invalid")
ErrLayeredSpecMissing = errors.New("dnf dungeon layered map specification is missing")
ErrLayeredSpecAmbiguous = errors.New("dnf dungeon layered map specification is ambiguous")
ErrLayeredIndexInvalid = errors.New("dnf dungeon layered map index is invalid")
ErrStoryStageMissing = errors.New("dnf dungeon story stage is missing")
ErrStoryStageIndexInvalid = errors.New("dnf dungeon story stage index is invalid")
)
type MapFileType string
const (
MapFileNormal MapFileType = "normal"
MapFileStart MapFileType = "start"
MapFileBoss MapFileType = "boss"
MapFileNamed MapFileType = "named"
MapFileEnd MapFileType = "end"
MapFileHidden MapFileType = "hidden"
MapFileQuest MapFileType = "quest"
MapFileDefault MapFileType = "default"
)
type ResolutionSource string
const (
ResolutionMapSpecification ResolutionSource = "map_specification"
ResolutionDungeonOwnership ResolutionSource = "dungeon_ownership"
ResolutionDirectoryPath ResolutionSource = "directory_coordinate"
ResolutionTypePool ResolutionSource = "type_pool"
ResolutionHellParty ResolutionSource = "hell_party_seal_door"
)
type ResolveRequest struct {
DungeonID int64 `json:"dungeon_id"`
MazeIndex int `json:"maze_index"`
X int64 `json:"x"`
Y int64 `json:"y"`
}
type ResolvedMap struct {
Map Map `json:"map"`
Source ResolutionSource `json:"source"`
SpecificationType string `json:"specification_type,omitempty"`
}
// DungeonStoryStage is one client-side PVF map-candidate advance in an
// activity story maze. Index and map order are derived from the repeated
// [boss map] coordinate sequence and the matching [map specification] rows.
type DungeonStoryStage struct {
Index int `json:"index"`
Coordinate RoomCoordinate `json:"coordinate"`
MapID int64 `json:"map_id"`
SpecificationType string `json:"specification_type"`
}
type DungeonMapIndexSnapshot struct {
DungeonID int64 `json:"dungeon_id"`
Mazes int `json:"mazes"`
Specifications int `json:"specifications"`
OwnershipEntries int `json:"ownership_entries"`
CoordinateEntries int `json:"coordinate_entries"`
PathEntries int `json:"path_entries"`
}
type Resolver struct {
table *Table
byDungeon map[int64]dungeonMapIndex
}
type coordinateKey struct {
x int64
y int64
}
type indexedMap struct {
mapPos int
fileType MapFileType
specificationType string
}
type mazeMapIndex struct {
bySpecification map[coordinateKey][]indexedMap
byOwnership map[coordinateKey][]indexedMap
byCoordinate map[coordinateKey][]indexedMap
byPool map[MapFileType][]indexedMap
byPath map[string]indexedMap
layeredByRoom map[coordinateKey]map[int]struct{}
layeredMaps map[int]struct{}
storyStages []indexedStoryStage
storyMapsByRoom map[coordinateKey]map[int]struct{}
specifications int
}
type indexedStoryStage struct {
coordinate coordinateKey
mapPos int
specificationType string
}
type dungeonMapIndex struct {
dungeonPos int
mazes []mazeMapIndex
}
var mapCoordinatePattern = regexp.MustCompile(`\((-?\d+)[,.](-?\d+)\)`)
func NewResolver(table *Table) (*Resolver, error) {
if table == nil {
return nil, ErrResolverTableRequired
}
resolver := &Resolver{table: table, byDungeon: make(map[int64]dungeonMapIndex, len(table.dungeons))}
mapsByDirectory := indexMapsByDirectory(table.maps)
mapsByDungeon := indexMapsByDungeon(table.maps)
for dungeonPos, dungeon := range table.dungeons {
if _, exists := resolver.byDungeon[dungeon.ID]; exists {
continue
}
index := dungeonMapIndex{dungeonPos: dungeonPos, mazes: make([]mazeMapIndex, len(dungeon.Mazes))}
for mazePos, maze := range dungeon.Mazes {
mazeIndex, err := buildMazeMapIndex(table, dungeon, maze, mapsByDirectory, mapsByDungeon[dungeon.ID])
if err != nil {
return nil, err
}
index.mazes[mazePos] = mazeIndex
}
resolver.byDungeon[dungeon.ID] = index
}
return resolver, nil
}
func buildMazeMapIndex(
table *Table,
dungeon Dungeon,
maze Maze,
mapsByDirectory map[string][]int,
ownedMaps []int,
) (mazeMapIndex, error) {
index := mazeMapIndex{
bySpecification: make(map[coordinateKey][]indexedMap),
byOwnership: make(map[coordinateKey][]indexedMap),
byCoordinate: make(map[coordinateKey][]indexedMap),
byPool: make(map[MapFileType][]indexedMap),
byPath: make(map[string]indexedMap),
layeredByRoom: make(map[coordinateKey]map[int]struct{}),
layeredMaps: make(map[int]struct{}),
storyMapsByRoom: make(map[coordinateKey]map[int]struct{}),
}
if err := buildMazeStoryStageIndex(table, dungeon, maze, &index); err != nil {
return mazeMapIndex{}, err
}
directories := make(map[string]struct{})
for _, specification := range maze.MapSpecifications {
if isLayeredSpecification(specification) {
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
return mazeMapIndex{}, err
}
continue
}
if err := addSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
return mazeMapIndex{}, err
}
}
for _, specification := range maze.BossSpecifications {
if isLayeredSpecification(specification) {
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
return mazeMapIndex{}, err
}
continue
}
if err := addSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
return mazeMapIndex{}, err
}
}
for _, specification := range maze.LayeredSpecifications {
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
return mazeMapIndex{}, err
}
}
filterStoryStageSpecificationCandidates(&index)
for directory := range directories {
for _, mapPos := range mapsByDirectory[directory] {
mapValue := table.maps[mapPos]
ref := indexedMap{mapPos: mapPos, fileType: ClassifyMapFileType(mapValue.Path)}
index.byPath[pathKey(mapValue.Path)] = ref
// A start room must have a map: the client cannot load the entrance
// without one, while an unmapped non-start cell is legal (see
// dungeon_layout.go's ErrMapPoolEmpty branch). Some dungeons place
// [start map] on a coordinate that no [map specification] entry covers
// and own no [dungeon] maps at all, so bySpecification, byOwnership and
// byCoordinate all miss and the old byPool (owned, coordinate-less maps
// only) was empty too -- Anton 219 blackvolcano declares start (0,1)
// while its resources sit at (0,0)..(6,0) under map/219_blackvolcano,
// which made the gate permanently unenterable ("build dungeon layout:
// assign dungeon room map 0:1: ... pool is empty: dungeon=219
// maze=0 file_type=start", live 2026-09-16T15:01:58Z). Publishing the
// referenced directory's start map into byPool keeps that last-resort
// fallback available without inventing maps for non-start cells.
if ref.fileType == MapFileStart {
index.byPool[MapFileStart] = appendUniqueIndexedMap(index.byPool[MapFileStart], ref)
}
x, y, ok := ParseMapFileCoordinate(mapValue.Path)
if !ok {
continue
}
key := coordinateKey{x: x, y: y}
if index.isLayeredMapAt(key, mapPos) {
continue
}
if index.isStoryMapAt(key, mapPos) {
continue
}
index.byCoordinate[key] = appendUniqueIndexedMap(index.byCoordinate[key], ref)
}
}
for _, mapPos := range ownedMaps {
mapValue := table.maps[mapPos]
ref := indexedMap{mapPos: mapPos, fileType: ClassifyMapFileType(mapValue.Path)}
index.byPath[pathKey(mapValue.Path)] = ref
x, y, ok := ParseMapFileCoordinate(mapValue.Path)
if !ok {
if _, layered := index.layeredMaps[mapPos]; layered {
continue
}
index.byPool[ref.fileType] = appendUniqueIndexedMap(index.byPool[ref.fileType], ref)
continue
}
key := coordinateKey{x: x, y: y}
if index.isLayeredMapAt(key, mapPos) {
continue
}
if index.isStoryMapAt(key, mapPos) {
continue
}
index.byOwnership[key] = appendUniqueIndexedMap(index.byOwnership[key], ref)
}
return index, nil
}
func buildMazeStoryStageIndex(
table *Table,
dungeon Dungeon,
maze Maze,
index *mazeMapIndex,
) error {
coordinates := mazeBossCoordinates(maze.Boss)
if len(coordinates) < 2 || !hasRepeatedRoomCoordinate(coordinates) || !mazeQuestConnected(maze) {
return nil
}
specificationsByRoom := make(map[coordinateKey][]MapSpecification)
appendSpecifications := func(specifications []MapSpecification) {
for _, specification := range specifications {
if isLayeredSpecification(specification) || len(specification.MapIDs) == 0 {
continue
}
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
specificationsByRoom[key] = append(specificationsByRoom[key], specification)
}
}
appendSpecifications(maze.MapSpecifications)
appendSpecifications(maze.BossSpecifications)
used := make(map[coordinateKey]int)
stages := make([]indexedStoryStage, 0, len(coordinates))
for _, coordinate := range coordinates {
key := coordinateKey{x: coordinate.X, y: coordinate.Y}
position := used[key]
specifications := specificationsByRoom[key]
if position >= len(specifications) {
return nil
}
specification := specifications[position]
used[key] = position + 1
mapID := specification.MapIDs[0]
mapPos, ok := table.mapByID[mapID]
if !ok {
return fmt.Errorf(
"%w: dungeon=%d maze=%d story_stage=%d coordinate=(%d,%d) map_id=%d",
ErrMapReferenceMissing, dungeon.ID, maze.Index, len(stages), key.x, key.y, mapID,
)
}
stages = append(stages, indexedStoryStage{
coordinate: key, mapPos: mapPos, specificationType: specification.Type,
})
}
for _, stage := range stages {
if index.storyMapsByRoom[stage.coordinate] == nil {
index.storyMapsByRoom[stage.coordinate] = make(map[int]struct{})
}
index.storyMapsByRoom[stage.coordinate][stage.mapPos] = struct{}{}
}
index.storyStages = stages
return nil
}
func hasRepeatedRoomCoordinate(coordinates []RoomCoordinate) bool {
seen := make(map[RoomCoordinate]struct{}, len(coordinates))
for _, coordinate := range coordinates {
if _, ok := seen[coordinate]; ok {
return true
}
seen[coordinate] = struct{}{}
}
return false
}
func filterStoryStageSpecificationCandidates(index *mazeMapIndex) {
if index == nil || len(index.storyStages) == 0 {
return
}
firstStageByRoom := make(map[coordinateKey]int)
for _, stage := range index.storyStages {
if _, exists := firstStageByRoom[stage.coordinate]; !exists {
firstStageByRoom[stage.coordinate] = stage.mapPos
}
}
for key, storyMaps := range index.storyMapsByRoom {
candidates := index.bySpecification[key]
ordinary := make([]indexedMap, 0, len(candidates))
for _, candidate := range candidates {
if _, story := storyMaps[candidate.mapPos]; !story {
ordinary = appendUniqueIndexedMap(ordinary, candidate)
}
}
if len(ordinary) != 0 {
index.bySpecification[key] = ordinary
continue
}
fallbackMapPos, ok := firstStageByRoom[key]
if !ok {
continue
}
for _, candidate := range candidates {
if candidate.mapPos == fallbackMapPos {
index.bySpecification[key] = []indexedMap{candidate}
break
}
}
}
}
func isLayeredSpecification(specification MapSpecification) bool {
return sectionKey(strings.Trim(specification.Type, "[]")) == "layered"
}
func addLayeredSpecificationToMazeIndex(
table *Table,
dungeon Dungeon,
maze Maze,
specification MapSpecification,
index *mazeMapIndex,
directories map[string]struct{},
) error {
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
index.specifications++
if index.layeredByRoom[key] == nil {
index.layeredByRoom[key] = make(map[int]struct{})
}
for _, mapID := range specification.MapIDs {
mapPos, ok := table.mapByID[mapID]
if !ok {
return fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_id=%d",
ErrMapReferenceMissing, dungeon.ID, maze.Index, key.x, key.y, mapID,
)
}
index.layeredByRoom[key][mapPos] = struct{}{}
index.layeredMaps[mapPos] = struct{}{}
directory := pathKey(path.Dir(strings.ReplaceAll(table.maps[mapPos].Path, "\\", "/")))
if directory != "" && directory != "." {
directories[directory] = struct{}{}
}
}
return nil
}
func (index mazeMapIndex) isLayeredMapAt(key coordinateKey, mapPos int) bool {
_, ok := index.layeredByRoom[key][mapPos]
return ok
}
func (index mazeMapIndex) isStoryMapAt(key coordinateKey, mapPos int) bool {
_, ok := index.storyMapsByRoom[key][mapPos]
return ok
}
func indexMapsByDirectory(maps []Map) map[string][]int {
out := make(map[string][]int)
for mapPos, mapValue := range maps {
directory := pathKey(path.Dir(strings.ReplaceAll(mapValue.Path, "\\", "/")))
out[directory] = append(out[directory], mapPos)
}
return out
}
func indexMapsByDungeon(maps []Map) map[int64][]int {
out := make(map[int64][]int)
for mapPos, mapValue := range maps {
for _, dungeonID := range mapValue.DungeonIDs {
out[dungeonID] = append(out[dungeonID], mapPos)
}
}
return out
}
func addSpecificationToMazeIndex(
table *Table,
dungeon Dungeon,
maze Maze,
specification MapSpecification,
index *mazeMapIndex,
directories map[string]struct{},
) error {
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
index.specifications++
for _, mapID := range specification.MapIDs {
mapPos, ok := table.mapByID[mapID]
if !ok {
return fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_id=%d",
ErrMapReferenceMissing, dungeon.ID, maze.Index, key.x, key.y, mapID,
)
}
mapValue := table.maps[mapPos]
ref := indexedMap{
mapPos: mapPos, fileType: specificationFileType(specification.Type),
specificationType: specification.Type,
}
index.bySpecification[key] = appendUniqueIndexedMap(index.bySpecification[key], ref)
index.byPath[pathKey(mapValue.Path)] = ref
directory := pathKey(path.Dir(strings.ReplaceAll(mapValue.Path, "\\", "/")))
if directory != "" && directory != "." {
directories[directory] = struct{}{}
}
}
return nil
}
func (r *Resolver) Resolve(request ResolveRequest) (ResolvedMap, error) {
candidates, source, err := r.resolveCandidateSet(request)
if err != nil {
return ResolvedMap{}, err
}
return r.resolveCandidates(request, candidates, source)
}
// ResolveLayered resolves exactly one PVF-declared layered map at a maze
// coordinate. layerIndex is positional in the specification's MapIDs list;
// directory ownership and filename-coordinate fallbacks are deliberately not
// consulted. Both inline `layered` rows in [map specification] and typed
// [layered map specification] rows are explicit PVF declarations.
func (r *Resolver) ResolveLayered(
dungeonID int64,
mazeIndex int,
coordinate RoomCoordinate,
layerIndex int,
) (ResolvedMap, error) {
if r == nil || r.table == nil {
return ResolvedMap{}, ErrResolverTableRequired
}
dungeonIndex, ok := r.byDungeon[dungeonID]
if !ok {
return ResolvedMap{}, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
}
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
return ResolvedMap{}, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
}
dungeon := r.table.dungeons[dungeonIndex.dungeonPos]
maze := dungeon.Mazes[mazeIndex]
specifications := explicitLayeredSpecificationsAt(maze, coordinate)
if len(specifications) == 0 {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=%s",
ErrLayeredSpecMissing,
dungeonID,
mazeIndex,
coordinate,
)
}
if len(specifications) != 1 {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=%s specifications=%d",
ErrLayeredSpecAmbiguous,
dungeonID,
mazeIndex,
coordinate,
len(specifications),
)
}
specification := specifications[0]
if layerIndex < 0 || layerIndex >= len(specification.MapIDs) {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=%s layer=%d count=%d",
ErrLayeredIndexInvalid,
dungeonID,
mazeIndex,
coordinate,
layerIndex,
len(specification.MapIDs),
)
}
mapID := specification.MapIDs[layerIndex]
mapPos, ok := r.table.mapByID[mapID]
if !ok {
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=%s layer=%d map_id=%d",
ErrMapReferenceMissing,
dungeonID,
mazeIndex,
coordinate,
layerIndex,
mapID,
)
}
return ResolvedMap{
Map: cloneMap(r.table.maps[mapPos]),
Source: ResolutionMapSpecification,
SpecificationType: "layered",
}, nil
}
// StoryStages returns the ordered activity-story candidate chain declared by
// the active maze. A maze is treated as staged only when its quest-connected
// [boss map] sequence repeats a coordinate and every occurrence can be paired
// with a matching non-layered map specification in declaration order.
func (r *Resolver) StoryStages(dungeonID int64, mazeIndex int) ([]DungeonStoryStage, error) {
index, err := r.mazeIndex(dungeonID, mazeIndex)
if err != nil {
return nil, err
}
if len(index.storyStages) == 0 {
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrStoryStageMissing, dungeonID, mazeIndex)
}
out := make([]DungeonStoryStage, len(index.storyStages))
for stageIndex, stage := range index.storyStages {
out[stageIndex] = DungeonStoryStage{
Index: stageIndex,
Coordinate: RoomCoordinate{X: stage.coordinate.x, Y: stage.coordinate.y},
MapID: r.table.maps[stage.mapPos].ID,
SpecificationType: stage.specificationType,
}
}
return out, nil
}
func (r *Resolver) ResolveStoryStage(dungeonID int64, mazeIndex int, stageIndex int) (ResolvedMap, DungeonStoryStage, error) {
index, err := r.mazeIndex(dungeonID, mazeIndex)
if err != nil {
return ResolvedMap{}, DungeonStoryStage{}, err
}
if len(index.storyStages) == 0 {
return ResolvedMap{}, DungeonStoryStage{}, fmt.Errorf(
"%w: dungeon=%d maze=%d", ErrStoryStageMissing, dungeonID, mazeIndex,
)
}
if stageIndex < 0 || stageIndex >= len(index.storyStages) {
return ResolvedMap{}, DungeonStoryStage{}, fmt.Errorf(
"%w: dungeon=%d maze=%d stage=%d count=%d",
ErrStoryStageIndexInvalid, dungeonID, mazeIndex, stageIndex, len(index.storyStages),
)
}
stage := index.storyStages[stageIndex]
descriptor := DungeonStoryStage{
Index: stageIndex,
Coordinate: RoomCoordinate{X: stage.coordinate.x, Y: stage.coordinate.y},
MapID: r.table.maps[stage.mapPos].ID,
SpecificationType: stage.specificationType,
}
return ResolvedMap{
Map: cloneMap(r.table.maps[stage.mapPos]),
Source: ResolutionMapSpecification,
SpecificationType: stage.specificationType,
}, descriptor, nil
}
func explicitLayeredSpecificationsAt(maze Maze, coordinate RoomCoordinate) []MapSpecification {
specifications := make([]MapSpecification, 0, 1)
appendMatching := func(values []MapSpecification, requireLayeredType bool) {
for _, specification := range values {
if requireLayeredType && sectionKey(strings.Trim(specification.Type, "[]")) != "layered" {
continue
}
if specification.Coordinate.X != coordinate.X || specification.Coordinate.Y != coordinate.Y {
continue
}
specifications = append(specifications, specification)
}
}
appendMatching(maze.MapSpecifications, true)
appendMatching(maze.LayeredSpecifications, false)
return specifications
}
func (r *Resolver) ResolveCandidates(request ResolveRequest) ([]ResolvedMap, error) {
candidates, source, err := r.resolveCandidateSet(request)
if err != nil {
return nil, err
}
candidates = uniqueIndexedMaps(candidates)
out := make([]ResolvedMap, 0, len(candidates))
for _, candidate := range candidates {
out = append(out, ResolvedMap{
Map: cloneMap(r.table.maps[candidate.mapPos]),
Source: source,
SpecificationType: candidate.specificationType,
})
}
return out, nil
}
func (r *Resolver) resolveCandidateSet(request ResolveRequest) ([]indexedMap, ResolutionSource, error) {
if r == nil || r.table == nil {
return nil, "", ErrResolverTableRequired
}
dungeonIndex, ok := r.byDungeon[request.DungeonID]
if !ok {
return nil, "", fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, request.DungeonID)
}
if request.MazeIndex < 0 || request.MazeIndex >= len(dungeonIndex.mazes) {
return nil, "", fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, request.DungeonID, request.MazeIndex)
}
dungeon := r.table.dungeons[dungeonIndex.dungeonPos]
maze := dungeon.Mazes[request.MazeIndex]
index := dungeonIndex.mazes[request.MazeIndex]
key := coordinateKey{x: request.X, y: request.Y}
expectedType := expectedMapFileType(maze, key)
if len(index.bySpecification[key]) == 0 && !mazeQuestConnected(maze) &&
(expectedType == MapFileStart || expectedType == MapFileBoss) {
if candidates := exactFileTypeCandidates(index.byOwnership[key], expectedType); len(candidates) > 0 {
return candidates, ResolutionDungeonOwnership, nil
}
if candidates := exactFileTypeCandidates(index.byCoordinate[key], expectedType); len(candidates) > 0 {
return candidates, ResolutionDirectoryPath, nil
}
}
if candidates := selectSpecificationCandidates(index.bySpecification[key], maze, key); len(candidates) > 0 {
return candidates, ResolutionMapSpecification, nil
}
if candidates := exactFileTypeCandidates(index.byOwnership[key], expectedType); len(candidates) > 0 {
return candidates, ResolutionDungeonOwnership, nil
}
var exact []indexedMap
for _, candidate := range index.byCoordinate[key] {
if candidate.fileType == expectedType {
exact = appendUniqueIndexedMap(exact, candidate)
}
}
if len(exact) > 0 {
return exact, ResolutionDirectoryPath, nil
}
start, boss := mazeRoomRoles(maze, key)
for _, fallbackType := range startBossCoordinateFallbackFileTypes(expectedType, start, boss) {
if candidates := exactFileTypeCandidates(index.byOwnership[key], fallbackType); len(candidates) > 0 {
return candidates, ResolutionDungeonOwnership, nil
}
if candidates := exactFileTypeCandidates(index.byCoordinate[key], fallbackType); len(candidates) > 0 {
return candidates, ResolutionDirectoryPath, nil
}
}
return nil, "", fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=(%d,%d) expected_type=%s",
ErrMapUnresolved, request.DungeonID, request.MazeIndex, request.X, request.Y, expectedType,
)
}
type MapPoolRequest struct {
DungeonID int64 `json:"dungeon_id"`
MazeIndex int `json:"maze_index"`
FileType MapFileType `json:"file_type"`
}
func (r *Resolver) PoolCandidates(request MapPoolRequest) ([]Map, error) {
index, err := r.mazeIndex(request.DungeonID, request.MazeIndex)
if err != nil {
return nil, err
}
refs := uniqueIndexedMaps(index.byPool[request.FileType])
if len(refs) == 0 {
return nil, fmt.Errorf(
"%w: dungeon=%d maze=%d file_type=%s",
ErrMapPoolEmpty, request.DungeonID, request.MazeIndex, request.FileType,
)
}
out := make([]Map, 0, len(refs))
for _, ref := range refs {
out = append(out, cloneMap(r.table.maps[ref.mapPos]))
}
return out, nil
}
func (r *Resolver) ResolvePoolChoice(request MapPoolRequest, mapID int64) (ResolvedMap, error) {
candidates, err := r.PoolCandidates(request)
if err != nil {
return ResolvedMap{}, err
}
for _, candidate := range candidates {
if candidate.ID == mapID {
return ResolvedMap{Map: candidate, Source: ResolutionTypePool}, nil
}
}
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d file_type=%s map_id=%d",
ErrMapPoolChoiceInvalid, request.DungeonID, request.MazeIndex, request.FileType, mapID,
)
}
func (r *Resolver) mazeIndex(dungeonID int64, mazeIndex int) (mazeMapIndex, error) {
if r == nil || r.table == nil {
return mazeMapIndex{}, ErrResolverTableRequired
}
dungeonIndex, ok := r.byDungeon[dungeonID]
if !ok {
return mazeMapIndex{}, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
}
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
return mazeMapIndex{}, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
}
return dungeonIndex.mazes[mazeIndex], nil
}
func exactFileTypeCandidates(candidates []indexedMap, expectedType MapFileType) []indexedMap {
var exact []indexedMap
for _, candidate := range candidates {
if candidate.fileType == expectedType {
exact = appendUniqueIndexedMap(exact, candidate)
}
}
return exact
}
func (r *Resolver) TryResolve(request ResolveRequest) (ResolvedMap, bool) {
resolved, err := r.Resolve(request)
return resolved, err == nil
}
func (r *Resolver) ResolvePath(dungeonID int64, mazeIndex int, mapPath string) (Map, bool) {
if r == nil || r.table == nil {
return Map{}, false
}
dungeonIndex, ok := r.byDungeon[dungeonID]
if !ok || mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
return Map{}, false
}
ref, ok := dungeonIndex.mazes[mazeIndex].byPath[pathKey(mapPath)]
if !ok {
return Map{}, false
}
return cloneMap(r.table.maps[ref.mapPos]), true
}
func (r *Resolver) Snapshot(dungeonID int64) (DungeonMapIndexSnapshot, bool) {
if r == nil {
return DungeonMapIndexSnapshot{}, false
}
index, ok := r.byDungeon[dungeonID]
if !ok {
return DungeonMapIndexSnapshot{}, false
}
snapshot := DungeonMapIndexSnapshot{DungeonID: dungeonID, Mazes: len(index.mazes)}
for _, maze := range index.mazes {
snapshot.Specifications += maze.specifications
for _, entries := range maze.byOwnership {
snapshot.OwnershipEntries += len(entries)
}
for _, entries := range maze.byCoordinate {
snapshot.CoordinateEntries += len(entries)
}
snapshot.PathEntries += len(maze.byPath)
}
return snapshot, true
}
func (r *Resolver) resolveCandidates(request ResolveRequest, candidates []indexedMap, source ResolutionSource) (ResolvedMap, error) {
candidates = uniqueIndexedMaps(candidates)
if len(candidates) != 1 {
ids := make([]int64, 0, len(candidates))
for _, candidate := range candidates {
ids = append(ids, r.table.maps[candidate.mapPos].ID)
}
return ResolvedMap{}, fmt.Errorf(
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_ids=%v",
ErrMapAmbiguous, request.DungeonID, request.MazeIndex, request.X, request.Y, ids,
)
}
candidate := candidates[0]
return ResolvedMap{
Map: cloneMap(r.table.maps[candidate.mapPos]), Source: source,
SpecificationType: candidate.specificationType,
}, nil
}
func selectSpecificationCandidates(candidates []indexedMap, maze Maze, key coordinateKey) []indexedMap {
if len(candidates) == 0 {
return nil
}
expected := expectedMapFileType(maze, key)
var typed []indexedMap
for _, candidate := range candidates {
if candidate.fileType == expected {
typed = appendUniqueIndexedMap(typed, candidate)
}
}
if len(typed) > 0 {
return typed
}
return uniqueIndexedMaps(candidates)
}
func expectedMapFileType(maze Maze, key coordinateKey) MapFileType {
if maze.Boss != nil && maze.Boss.X == key.x && maze.Boss.Y == key.y {
return MapFileBoss
}
if maze.Start != nil && maze.Start.X == key.x && maze.Start.Y == key.y {
return MapFileStart
}
if mazeQuestConnected(maze) {
return MapFileQuest
}
return MapFileNormal
}
func mazeRoomRoles(maze Maze, key coordinateKey) (start bool, boss bool) {
start = maze.Start != nil && maze.Start.X == key.x && maze.Start.Y == key.y
for _, coordinate := range mazeBossCoordinates(maze.Boss) {
if coordinate.X == key.x && coordinate.Y == key.y {
boss = true
break
}
}
return start, boss
}
func startBossCoordinateFallbackFileTypes(fileType MapFileType, start bool, boss bool) []MapFileType {
if fileType == MapFileStart && start && !boss {
// Quest/story maze variants commonly declare [start map] without a
// matching start-typed MAP or [map specification]. A dungeon-owned MAP
// at that exact coordinate with a normal filename is the PVF's concrete
// entrance resource (for example DraconianTower b_1639(0,1)n.map).
// Exact specifications and exact start types have already won above.
return []MapFileType{MapFileNormal}
}
if fileType != MapFileBoss || !start || !boss {
return nil
}
// Single-room floors mark their only coordinate as both start and boss.
// The owned MAP may be either an explicitly start-named resource (the
// training room PoongjinTrainingRoom/11100(0,0)start.map) or a plain
// normal resource whose filename only embeds the coordinate (the job-change
// trial 7142 ChangeClass/76032_ChangeClass_S_(0,0).map). Prefer the exact
// normal owned map first, then fall back to a start-typed map, matching the
// start==boss pool shape used by dungeonLayoutFallbackPoolFileTypes.
return []MapFileType{MapFileNormal, MapFileStart}
}
func mazeQuestConnected(maze Maze) bool {
return len(maze.QuestConnection) >= 2
}
func specificationFileType(value string) MapFileType {
switch sectionKey(strings.Trim(value, "[]")) {
case "start":
return MapFileStart
case "boss":
return MapFileBoss
case "named":
return MapFileNamed
case "end":
return MapFileEnd
case "hidden":
return MapFileHidden
case "quest":
return MapFileQuest
case "default":
return MapFileDefault
default:
return MapFileNormal
}
}
func ParseMapFileCoordinate(mapPath string) (int64, int64, bool) {
stem := strings.TrimSuffix(path.Base(strings.ReplaceAll(mapPath, "\\", "/")), path.Ext(mapPath))
match := mapCoordinatePattern.FindStringSubmatch(stem)
if len(match) != 3 {
return 0, 0, false
}
var x, y int64
if _, err := fmt.Sscan(match[1], &x); err != nil {
return 0, 0, false
}
if _, err := fmt.Sscan(match[2], &y); err != nil {
return 0, 0, false
}
return x, y, true
}
func ClassifyMapFileType(mapPath string) MapFileType {
stem := strings.TrimSuffix(path.Base(strings.ReplaceAll(mapPath, "\\", "/")), path.Ext(mapPath))
lower := strings.ToLower(stem)
if strings.HasPrefix(lower, "q_") || strings.HasPrefix(lower, "quest") || prefixedDigit(lower, "q") {
return MapFileQuest
}
if strings.HasPrefix(lower, "bn") && len(lower) > 2 && isASCIIDigit(lower[2]) {
return MapFileNamed
}
if strings.Contains(lower, "boss") || prefixedDigit(lower, "b") || typedSuffix(lower, 'b') {
return MapFileBoss
}
if strings.Contains(lower, "start") || prefixedDigit(lower, "s") || typedSuffix(lower, 's') {
return MapFileStart
}
if prefixedDigit(lower, "e") {
return MapFileEnd
}
if prefixedDigit(lower, "h") {
return MapFileHidden
}
if prefixedDigit(lower, "d") {
return MapFileDefault
}
return MapFileNormal
}
func prefixedDigit(value, prefix string) bool {
return strings.HasPrefix(value, prefix) && len(value) > len(prefix) && isASCIIDigit(value[len(prefix)])
}
func typedSuffix(value string, suffix byte) bool {
if len(value) < 2 || value[len(value)-1] != suffix {
return false
}
previous := value[len(value)-2]
return isASCIIDigit(previous) || previous == ')'
}
func isASCIIDigit(value byte) bool {
return value >= '0' && value <= '9'
}
func appendUniqueIndexedMap(target []indexedMap, candidate indexedMap) []indexedMap {
for _, existing := range target {
if existing.mapPos == candidate.mapPos && existing.fileType == candidate.fileType {
return target
}
}
return append(target, candidate)
}
func uniqueIndexedMaps(values []indexedMap) []indexedMap {
var out []indexedMap
seen := make(map[int]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value.mapPos]; ok {
continue
}
seen[value.mapPos] = struct{}{}
out = append(out, value)
}
return out
}
@@ -0,0 +1,450 @@
package worldmap
import (
"errors"
"testing"
)
func TestResolverUsesSpecificationAndExactDirectoryCoordinates(t *testing.T) {
table := resolverTestTable()
resolver, err := NewResolver(table)
if err != nil {
t.Fatalf("new resolver: %v", err)
}
start, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 0, Y: 0})
if err != nil || start.Map.ID != 100 || start.Source != ResolutionMapSpecification {
t.Fatalf("start resolution = %+v err=%v", start, err)
}
boss, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 1, Y: 0})
if err != nil || boss.Map.ID != 101 || boss.Source != ResolutionMapSpecification {
t.Fatalf("boss resolution = %+v err=%v", boss, err)
}
normal, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 0, Y: 1})
if err != nil || normal.Map.ID != 102 || normal.Source != ResolutionDirectoryPath {
t.Fatalf("directory resolution = %+v err=%v", normal, err)
}
if byPath, ok := resolver.ResolvePath(700, 0, "./MAP/ALPHA/102(0,1).MAP"); !ok || byPath.ID != 102 {
t.Fatalf("path lookup = %+v %v", byPath, ok)
}
if _, ok := resolver.ResolvePath(700, 0, "map/other/200(9,9).map"); ok {
t.Fatal("resolver accepted a map outside explicit specification directories")
}
snapshot, ok := resolver.Snapshot(700)
if !ok || snapshot.Mazes != 1 || snapshot.Specifications != 2 || snapshot.CoordinateEntries < 5 {
t.Fatalf("resolver snapshot = %+v %v", snapshot, ok)
}
}
func TestResolverReturnsExplicitErrorsWithoutFallback(t *testing.T) {
resolver, err := NewResolver(resolverTestTable())
if err != nil {
t.Fatal(err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 2, Y: 0})
if !errors.Is(err, ErrMapAmbiguous) {
t.Fatalf("expected ErrMapAmbiguous, got %v", err)
}
candidates, err := resolver.ResolveCandidates(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 2, Y: 0})
if err != nil || len(candidates) != 2 || candidates[0].Map.ID != 103 || candidates[1].Map.ID != 104 {
t.Fatalf("explicit ambiguity candidates = %+v err=%v", candidates, err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 3, Y: 0})
if !errors.Is(err, ErrMapUnresolved) {
t.Fatalf("boss-typed path must not satisfy a normal room: %v", err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 99, Y: 99})
if !errors.Is(err, ErrMapUnresolved) {
t.Fatalf("expected ErrMapUnresolved, got %v", err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 999, MazeIndex: 0, X: 0, Y: 0})
if !errors.Is(err, ErrDungeonNotIndexed) {
t.Fatalf("expected ErrDungeonNotIndexed, got %v", err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 9, X: 0, Y: 0})
if !errors.Is(err, ErrMazeNotFound) {
t.Fatalf("expected ErrMazeNotFound, got %v", err)
}
if _, ok := resolver.TryResolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 99, Y: 99}); ok {
t.Fatal("TryResolve reported success for an unresolved room")
}
}
func TestResolverUsesMapDungeonOwnershipWithoutSpecificationFallback(t *testing.T) {
table := resolverTestTable()
table.maps = append(table.maps,
Map{ID: 300, Path: "map/owned/300(0,0)start.map", DungeonIDs: []int64{701}},
Map{ID: 301, Path: "map/owned/301(1,0)normal.map", DungeonIDs: []int64{701}},
Map{ID: 302, Path: "map/owned/302(2,0)normal.map", DungeonIDs: []int64{701}},
Map{ID: 303, Path: "map/owned/303(2,0)normal.map", DungeonIDs: []int64{701}},
)
table.dungeons = append(table.dungeons, Dungeon{
ID: 701, Path: "dungeon/owned.dgn",
Mazes: []Maze{{
Index: 0, Start: &MazePoint{X: 0, Y: 0}, Width: OptionalInt{Value: 3, Set: true},
Height: OptionalInt{Value: 1, Set: true},
}},
})
table.dungeonByID[701] = 1
table.dungeonByPath["dungeon/owned.dgn"] = 1
for pos := len(table.maps) - 4; pos < len(table.maps); pos++ {
table.mapByID[table.maps[pos].ID] = pos
table.mapByPath[pathKey(table.maps[pos].Path)] = pos
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
start, err := resolver.Resolve(ResolveRequest{DungeonID: 701, MazeIndex: 0, X: 0, Y: 0})
if err != nil || start.Map.ID != 300 || start.Source != ResolutionDungeonOwnership {
t.Fatalf("ownership start = %+v err=%v", start, err)
}
normal, err := resolver.Resolve(ResolveRequest{DungeonID: 701, MazeIndex: 0, X: 1, Y: 0})
if err != nil || normal.Map.ID != 301 || normal.Source != ResolutionDungeonOwnership {
t.Fatalf("ownership normal = %+v err=%v", normal, err)
}
if _, err := resolver.Resolve(ResolveRequest{DungeonID: 701, MazeIndex: 0, X: 2, Y: 0}); !errors.Is(err, ErrMapAmbiguous) {
t.Fatalf("ownership ambiguity = %v", err)
}
snapshot, ok := resolver.Snapshot(701)
if !ok || snapshot.OwnershipEntries != 4 {
t.Fatalf("ownership snapshot = %+v ok=%v", snapshot, ok)
}
}
func TestResolverExposesTypedPoolsWithoutChoosingRandomMap(t *testing.T) {
table := resolverTestTable()
table.maps = append(table.maps,
Map{ID: 400, Path: "map/pool/s400.map", DungeonIDs: []int64{702}},
Map{ID: 401, Path: "map/pool/s401.map", DungeonIDs: []int64{702}},
Map{ID: 402, Path: "map/pool/402.map", DungeonIDs: []int64{702}},
)
table.dungeons = append(table.dungeons, Dungeon{
ID: 702, Path: "dungeon/pool.dgn",
Mazes: []Maze{{Index: 0, Start: &MazePoint{X: 0, Y: 0}}},
})
table.dungeonByID[702] = 1
table.dungeonByPath["dungeon/pool.dgn"] = 1
for pos := len(table.maps) - 3; pos < len(table.maps); pos++ {
table.mapByID[table.maps[pos].ID] = pos
table.mapByPath[pathKey(table.maps[pos].Path)] = pos
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
request := MapPoolRequest{DungeonID: 702, MazeIndex: 0, FileType: MapFileStart}
candidates, err := resolver.PoolCandidates(request)
if err != nil || len(candidates) != 2 || candidates[0].ID != 400 || candidates[1].ID != 401 {
t.Fatalf("start pool = %+v err=%v", candidates, err)
}
resolved, err := resolver.ResolvePoolChoice(request, 401)
if err != nil || resolved.Map.ID != 401 || resolved.Source != ResolutionTypePool {
t.Fatalf("pool choice = %+v err=%v", resolved, err)
}
if _, err := resolver.ResolvePoolChoice(request, 402); !errors.Is(err, ErrMapPoolChoiceInvalid) {
t.Fatalf("wrong-type pool choice = %v", err)
}
if _, err := resolver.PoolCandidates(MapPoolRequest{DungeonID: 702, MazeIndex: 0, FileType: MapFileBoss}); !errors.Is(err, ErrMapPoolEmpty) {
t.Fatalf("empty boss pool = %v", err)
}
}
func TestResolverRejectsMissingAndAmbiguousSpecificationReferences(t *testing.T) {
table := resolverTestTable()
table.dungeons[0].Mazes[0].MapSpecifications[0].MapIDs = []int64{9999}
if _, err := NewResolver(table); !errors.Is(err, ErrMapReferenceMissing) {
t.Fatalf("expected ErrMapReferenceMissing, got %v", err)
}
table = resolverTestTable()
table.dungeons[0].Mazes[0].MapSpecifications[0].MapIDs = []int64{100, 105}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
_, err = resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 0, Y: 0})
if !errors.Is(err, ErrMapAmbiguous) {
t.Fatalf("expected explicit candidate ambiguity, got %v", err)
}
}
func TestResolverResolveLayeredUsesExplicitPVFOrder(t *testing.T) {
table := resolverTestTable()
appendResolverTestMap(table, Map{ID: 107, Path: "map/alpha/107(1,0).map"})
appendResolverTestMap(table, Map{ID: 108, Path: "map/alpha/108(1,0).map"})
table.dungeons[0].Mazes[0].MapSpecifications = append(
table.dungeons[0].Mazes[0].MapSpecifications,
MapSpecification{
Type: "layered",
Coordinate: Point{X: 1, Y: 0},
MapIDs: []int64{108, 107},
},
)
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
first, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 1, Y: 0}, 0)
if err != nil || first.Map.ID != 108 || first.Source != ResolutionMapSpecification || first.SpecificationType != "layered" {
t.Fatalf("first layered map=%+v err=%v", first, err)
}
second, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 1, Y: 0}, 1)
if err != nil || second.Map.ID != 107 {
t.Fatalf("second layered map=%+v err=%v", second, err)
}
// Ordinary resolution at the boss coordinate must keep selecting the boss
// map; layered maps are entered only through the explicit API.
boss, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 1, Y: 0})
if err != nil || boss.Map.ID != 101 {
t.Fatalf("ordinary boss resolution=%+v err=%v", boss, err)
}
if _, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 1, Y: 0}, -1); !errors.Is(err, ErrLayeredIndexInvalid) {
t.Fatalf("negative layer error=%v", err)
}
if _, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 1, Y: 0}, 2); !errors.Is(err, ErrLayeredIndexInvalid) {
t.Fatalf("exhausted layer error=%v", err)
}
if _, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 0, Y: 1}, 0); !errors.Is(err, ErrLayeredSpecMissing) {
t.Fatalf("missing layered specification error=%v", err)
}
ordinary, err := resolver.ResolveCandidates(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 1, Y: 0})
if err != nil || len(ordinary) != 1 || ordinary[0].Map.ID != 101 {
t.Fatalf("ordinary candidates leaked layered maps=%+v err=%v", ordinary, err)
}
if _, ok := resolver.ResolvePath(700, 0, "map/alpha/108(1,0).map"); !ok {
t.Fatal("path catalog unexpectedly lost an explicitly addressable layered map")
}
}
func TestResolverExcludesLayeredMapsFromOrdinaryOwnershipAndPoolsPerMaze(t *testing.T) {
table := resolverTestTable()
appendResolverTestMap(table, Map{ID: 107, Path: "map/alpha/107(0,1).map", DungeonIDs: []int64{700}})
appendResolverTestMap(table, Map{ID: 108, Path: "map/alpha/108.map", DungeonIDs: []int64{700}})
table.dungeons[0].Mazes[0].LayeredSpecifications = []MapSpecification{
{Type: "layered", Coordinate: Point{X: 0, Y: 1}, MapIDs: []int64{107, 108}},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
resolved, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 0, Y: 1})
if err != nil || resolved.Map.ID != 102 {
t.Fatalf("ordinary room selected layered ownership map=%+v err=%v", resolved, err)
}
pool, err := resolver.PoolCandidates(MapPoolRequest{DungeonID: 700, MazeIndex: 0, FileType: MapFileNormal})
if err == nil {
for _, candidate := range pool {
if candidate.ID == 108 {
t.Fatalf("ordinary pool leaked layered map=%+v", pool)
}
}
}
for index, want := range []int64{107, 108} {
layered, layerErr := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 0, Y: 1}, index)
if layerErr != nil || layered.Map.ID != want {
t.Fatalf("layer %d=%+v err=%v want=%d", index, layered, layerErr, want)
}
}
}
func TestResolverResolveLayeredAcceptsTypedSectionAndRejectsAmbiguity(t *testing.T) {
table := resolverTestTable()
appendResolverTestMap(table, Map{ID: 107, Path: "map/alpha/107(0,1).map"})
appendResolverTestMap(table, Map{ID: 108, Path: "map/alpha/108(0,1).map"})
table.dungeons[0].Mazes[0].LayeredSpecifications = []MapSpecification{{
Type: "layered",
Coordinate: Point{X: 0, Y: 1},
MapIDs: []int64{107, 108},
}}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
resolved, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 0, Y: 1}, 1)
if err != nil || resolved.Map.ID != 108 {
t.Fatalf("typed layered map=%+v err=%v", resolved, err)
}
table.dungeons[0].Mazes[0].MapSpecifications = append(
table.dungeons[0].Mazes[0].MapSpecifications,
MapSpecification{Type: "layered", Coordinate: Point{X: 0, Y: 1}, MapIDs: []int64{107}},
)
resolver, err = NewResolver(table)
if err != nil {
t.Fatal(err)
}
if _, err := resolver.ResolveLayered(700, 0, RoomCoordinate{X: 0, Y: 1}, 0); !errors.Is(err, ErrLayeredSpecAmbiguous) {
t.Fatalf("ambiguous layered specification error=%v", err)
}
table = resolverTestTable()
table.dungeons[0].Mazes[0].LayeredSpecifications = []MapSpecification{{
Type: "layered",
Coordinate: Point{X: 0, Y: 1},
MapIDs: []int64{9999},
}}
if _, err = NewResolver(table); !errors.Is(err, ErrMapReferenceMissing) {
t.Fatalf("missing typed layered reference error=%v", err)
}
}
func TestResolverBuildsRepeatedBossCoordinateStoryStagesWithoutRandomizingBase(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(2,0).map", Type: "[dummy]"})
appendResolverTestMap(table, Map{ID: 109, Path: "map/alpha/109(1,0)boss.map", Type: "[boss]"})
appendResolverTestMap(table, Map{ID: 110, Path: "map/alpha/110(2,0).map", Type: "[normal]"})
maze := &table.dungeons[0].Mazes[0]
maze.QuestConnection = []int64{0, 9000, -1}
maze.Boss = &MazePoint{X: 1, Y: 0, Params: []int64{2, 0, 1, 0}}
maze.MapSpecifications = []MapSpecification{
{Type: "map", Coordinate: Point{X: 0, Y: 0}, MapIDs: []int64{100}},
{Type: "map", Coordinate: Point{X: 1, Y: 0}, MapIDs: []int64{107}},
{Type: "map", Coordinate: Point{X: 2, Y: 0}, MapIDs: []int64{108, 110}},
{Type: "boss", Coordinate: Point{X: 1, Y: 0}, MapIDs: []int64{109}},
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
stages, err := resolver.StoryStages(700, 0)
if err != nil || len(stages) != 3 {
t.Fatalf("story stages=%+v err=%v", stages, err)
}
wantCoordinates := []RoomCoordinate{{X: 1, Y: 0}, {X: 2, Y: 0}, {X: 1, Y: 0}}
wantMapIDs := []int64{107, 108, 109}
for index := range wantMapIDs {
resolved, stage, resolveErr := resolver.ResolveStoryStage(700, 0, index)
if resolveErr != nil || resolved.Map.ID != wantMapIDs[index] ||
stage.Coordinate != wantCoordinates[index] || stage.MapID != wantMapIDs[index] {
t.Fatalf("stage %d resolved=%+v descriptor=%+v err=%v", index, resolved, stage, resolveErr)
}
}
firstBase, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 1, Y: 0})
if err != nil || firstBase.Map.ID != 107 {
t.Fatalf("first story coordinate base=%+v err=%v", firstBase, err)
}
secondBase, err := resolver.Resolve(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 2, Y: 0})
if err != nil || secondBase.Map.ID != 110 {
t.Fatalf("rendered base coordinate=%+v err=%v", secondBase, err)
}
ordinary, err := resolver.ResolveCandidates(ResolveRequest{DungeonID: 700, MazeIndex: 0, X: 2, Y: 0})
if err != nil || len(ordinary) != 1 || ordinary[0].Map.ID != 110 {
t.Fatalf("ordinary candidates leaked story stage=%+v err=%v", ordinary, err)
}
if _, _, err := resolver.ResolveStoryStage(700, 0, 3); !errors.Is(err, ErrStoryStageIndexInvalid) {
t.Fatalf("exhausted story stage error=%v", err)
}
}
func TestResolverStartBossSingleRoomFallsBackToNormalOwnedMap(t *testing.T) {
// The job-change trial 7142 marks its only coordinate (0,0) as both start
// and boss, and its owned MAP is a plain normal resource whose filename
// embeds the coordinate instead of an explicit "start"/"boss" token. The
// coordinate-owned fallback must accept that normal map so SELECT_DUNGEON
// can build the entrance layout instead of failing with a boss pool error.
table := resolverTestTable()
table.maps = append(table.maps,
Map{ID: 76032, Path: "map/Cataclysm/ChangeClass/76032_ChangeClass_S_(0,0).map", Type: "[normal]", DungeonIDs: []int64{7142}},
)
table.dungeons = append(table.dungeons, Dungeon{
ID: 7142, Path: "dungeon/Cataclysm/ChangeClass/7142_ChangeClass.dgn",
Mazes: []Maze{{
Index: 0, Start: &MazePoint{X: 0, Y: 0}, Boss: &MazePoint{X: 0, Y: 0},
Width: OptionalInt{Value: 1, Set: true}, Height: OptionalInt{Value: 1, Set: true},
}},
})
table.dungeonByID[7142] = 1
table.dungeonByPath["dungeon/Cataclysm/ChangeClass/7142_ChangeClass.dgn"] = 1
for pos := len(table.maps) - 1; pos < len(table.maps); pos++ {
table.mapByID[table.maps[pos].ID] = pos
table.mapByPath[pathKey(table.maps[pos].Path)] = pos
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
resolved, err := resolver.Resolve(ResolveRequest{DungeonID: 7142, MazeIndex: 0, X: 0, Y: 0})
if err != nil || resolved.Map.ID != 76032 || resolved.Source != ResolutionDungeonOwnership {
t.Fatalf("start==boss normal ownership resolution=%+v err=%v", resolved, err)
}
topology, err := BuildDungeonLayout(resolver, 7142, 0, nil)
if err != nil {
t.Fatalf("build dungeon layout: %v", err)
}
room, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || room.Map == nil || room.Map.Map.ID != 76032 {
t.Fatalf("room map after layout=%+v ok=%v", room, ok)
}
}
func TestMapPathClassificationAndCoordinates(t *testing.T) {
tests := []struct {
path string
typeValue MapFileType
x, y int64
hasCoordinate bool
}{
{"map/a/8147_(1.0)boss.map", MapFileBoss, 1, 0, true},
{"map/a/s407(4,0).map", MapFileStart, 4, 0, true},
{"map/a/q_12(-2,3).map", MapFileQuest, -2, 3, true},
{"map/a/bn12.map", MapFileNamed, 0, 0, false},
{"map/a/123.map", MapFileNormal, 0, 0, false},
}
for _, test := range tests {
if got := ClassifyMapFileType(test.path); got != test.typeValue {
t.Errorf("ClassifyMapFileType(%q) = %s, want %s", test.path, got, test.typeValue)
}
x, y, ok := ParseMapFileCoordinate(test.path)
if ok != test.hasCoordinate || x != test.x || y != test.y {
t.Errorf("ParseMapFileCoordinate(%q) = (%d,%d,%v)", test.path, x, y, ok)
}
}
}
func resolverTestTable() *Table {
maps := []Map{
{ID: 100, Path: "map/alpha/100(0,0).map"},
{ID: 101, Path: "map/alpha/101(1,0)boss.map"},
{ID: 102, Path: "map/alpha/102(0,1).map"},
{ID: 103, Path: "map/alpha/103(2,0).map"},
{ID: 104, Path: "map/alpha/104(2,0).map"},
{ID: 105, Path: "map/alpha/105(0,0).map"},
{ID: 106, Path: "map/alpha/106(3,0)boss.map"},
{ID: 200, Path: "map/other/200(9,9).map"},
}
dungeons := []Dungeon{{
ID: 700, Path: "dungeon/test.dgn",
Mazes: []Maze{{
Index: 0, Start: &MazePoint{X: 0, Y: 0}, Boss: &MazePoint{X: 1, Y: 0},
MapSpecifications: []MapSpecification{
{Type: "map", Coordinate: Point{X: 0, Y: 0}, MapIDs: []int64{100}},
{Type: "boss", Coordinate: Point{X: 1, Y: 0}, MapIDs: []int64{101}},
},
}},
}}
table := &Table{
maps: maps, dungeons: dungeons,
mapByID: make(map[int64]int), mapByPath: make(map[string]int),
dungeonByID: map[int64]int{700: 0}, dungeonByPath: map[string]int{"dungeon/test.dgn": 0},
}
for pos, mapValue := range maps {
table.mapByID[mapValue.ID] = pos
table.mapByPath[pathKey(mapValue.Path)] = pos
}
return table
}
func appendResolverTestMap(table *Table, mapValue Map) {
mapPos := len(table.maps)
table.maps = append(table.maps, mapValue)
table.mapByID[mapValue.ID] = mapPos
table.mapByPath[pathKey(mapValue.Path)] = mapPos
}
@@ -0,0 +1,184 @@
package worldmap
import (
"fmt"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var opaqueMapSections = stringSet(
"monster condition", "monster spawn pos", "darkness", "static player start pos",
"belt scroll map", "move layered map", "customized screen edge", "extended tile",
"scroll animation", "conditional summon monster", "map over move ani", "camera force move",
"camera edge exception", "revive with dlg", "zone defence", "tournament enemies",
"tournament start area", "before rendering info", "time line", "summon start area",
"map frame", "tile option", "background effect", "block effect", "item", "quest",
"apc create condition", "map animation", "revival map", "block path",
"monster specific ai", "buff", "map dialog", "dust",
)
var knownMapSections = stringSet(
"map name", "player number", "pvp start area", "dungeon", "type", "greed", "tile", "import script",
"tile files", "tile map", "summon", "summon key", "position", "itemcount", "index",
"life time", "info", "distance", "dungeon movable area", "random start map",
"pathgate recognize range",
"far sight scroll", "middle sight scroll", "near sight scroll", "background animation",
"pathgate pos", "sound", "animation", "passive object", "special passive object",
"monster", "event monster position", "npc", "monster specific ai", "buff", "ai character",
"fix champion", "heroes mode map index", "background correction", "background pos",
"foreground pattern alpha", "apc random point", "monster lock", "draw monster count",
"sort bottom", "add gravity", "jump power rate", "block use stackable item",
"block use active skill", "visible on dungeon clear", "loop y axis", "all dead case passable",
"disable item escape stuck", "disable character escape stuck", "cannot use coin map",
"no revival timer limit", "ignore diehard", "disable rebirth", "preserve player corpse",
"cannot use resolution change zoom", "center fixed camera", "force draw pattern", "is revival",
"is moive end", "is movie end", "quest start map", "hide monster", "show dust",
"dungeon start area", "screen pos", "monster team", "pvp practice start area",
"virtual movable area", "town movable area", "pathgate object", "opening bgm",
"map loading image path", "basic action", "map dialog", "dust", "absolute start path",
"monster condition", "monster spawn pos", "blood monster", "blood phase time",
"ultimate monster", "ultimate phase time", "darkness", "static player start pos",
"belt scroll map", "move layered map", "customized screen edge", "extended tile",
"scroll animation", "conditional summon monster", "map over move ani", "camera force move",
"camera edge exception", "revive with dlg", "zone defence", "tournament enemies",
"tournament start area", "before rendering info", "time line", "summon start area",
"map frame", "tile option", "background effect", "block effect", "item", "quest",
"apc create condition", "map animation", "revival map", "block path",
"ani info", "filename", "layer", "order", "hellparty",
)
var knownAreaSections = stringSet(
"map image", "ui path", "dungeon", "name", "hell dungeon", "hell quest",
"hell freepass item", "inferno freepass item", "particularhell freepass item",
"item condition", "in progress",
)
func stringSet(values ...string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, value := range values {
out[sectionKey(value)] = struct{}{}
}
return out
}
func sectionKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func rawSections(doc *dnfpvf.Document) ([]RawSection, []Diagnostic) {
if doc == nil {
return nil, []Diagnostic{{Message: "document is nil"}}
}
occurrences := make(map[string]int)
blocks := make(map[string]struct{})
for _, section := range doc.Sections {
key := sectionKey(section.Name)
if isClosingSection(key) {
blocks[strings.TrimPrefix(key, "/")] = struct{}{}
}
}
var scope []string
out := make([]RawSection, 0, len(doc.Sections))
var diagnostics []Diagnostic
for _, section := range doc.Sections {
key := sectionKey(section.Name)
if isClosingSection(key) {
closing := strings.TrimPrefix(key, "/")
match := -1
for index := len(scope) - 1; index >= 0; index-- {
if scope[index] == closing {
match = index
break
}
}
if match >= 0 {
scope = scope[:match]
} else {
diagnostics = append(diagnostics, Diagnostic{Section: section.Name, Message: "unmatched closing section"})
}
}
occurrences[key]++
_, block := blocks[key]
raw := RawSection{
Name: section.Name, Occurrence: occurrences[key], Scope: append([]string(nil), scope...), Block: block,
}
if section.Start < 0 || section.End < section.Start || section.End > len(doc.Tokens) {
diagnostics = append(diagnostics, Diagnostic{
Section: section.Name, Occurrence: raw.Occurrence,
Message: fmt.Sprintf("invalid token range [%d,%d) for %d tokens", section.Start, section.End, len(doc.Tokens)),
})
out = append(out, raw)
continue
}
raw.Tokens = append([]dnfpvf.Token(nil), doc.Tokens[section.Start:section.End]...)
out = append(out, raw)
if block && !isClosingSection(key) {
scope = append(scope, key)
}
}
if len(scope) > 0 {
diagnostics = append(diagnostics, Diagnostic{Section: scope[len(scope)-1], Message: "section block is not closed"})
}
return out, diagnostics
}
func isClosingSection(name string) bool {
return strings.HasPrefix(sectionKey(name), "/")
}
func ints(section RawSection) ([]int64, bool) {
out := make([]int64, 0, len(section.Tokens))
valid := true
for _, token := range section.Tokens {
switch token.Kind {
case dnfpvf.TokenInt:
out = append(out, token.Int)
case dnfpvf.TokenSymbol:
default:
valid = false
}
}
return out, valid
}
func values(section RawSection) []dnfpvf.Token {
out := make([]dnfpvf.Token, 0, len(section.Tokens))
for _, token := range section.Tokens {
if token.Kind != dnfpvf.TokenSymbol {
out = append(out, token)
}
}
return out
}
func texts(section RawSection) []string {
var out []string
for _, token := range section.Tokens {
if token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent {
out = append(out, token.Value)
}
}
return out
}
func firstText(section RawSection) (string, bool) {
all := texts(section)
if len(all) == 0 {
return "", false
}
return all[0], true
}
func firstInt(section RawSection) (int64, bool) {
for _, token := range section.Tokens {
if token.Kind == dnfpvf.TokenInt {
return token.Int, true
}
}
return 0, false
}
func diagnostic(section RawSection, message string) Diagnostic {
return Diagnostic{Section: section.Name, Occurrence: section.Occurrence, Message: message}
}
@@ -0,0 +1,489 @@
package worldmap
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFWorldMapSmoke(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the real Script.pvf smoke test")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open real pvf: %v", err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{DefaultWorldMapList}})
if err != nil {
t.Fatalf("build real worldmap index: %v", err)
}
table, err := Load(context.Background(), index, Options{SkipMaps: true, SkipDungeons: true})
if err != nil {
t.Fatalf("load real worldmap: %v", err)
}
if snapshot := table.Snapshot(); snapshot.Areas == 0 || snapshot.DungeonRefs == 0 {
t.Fatalf("empty real worldmap snapshot: %+v", snapshot)
}
listText, err := archive.ReadText(DefaultMapList)
if err != nil {
t.Fatalf("read real map list: %v", err)
}
listDoc, err := dnfpvf.Parse(DefaultMapList, listText)
if err != nil {
t.Fatalf("parse real map list: %v", err)
}
entries := dnfpvf.ParseList(listDoc)
if len(entries) == 0 {
t.Fatal("real map list is empty")
}
mapPath := strings.TrimPrefix(strings.ReplaceAll(entries[0].Path, "\\", "/"), "/")
if !strings.HasPrefix(strings.ToLower(mapPath), "map/") {
mapPath = "map/" + mapPath
}
mapText, err := archive.ReadText(mapPath)
if err != nil {
t.Fatalf("read real map %s: %v", mapPath, err)
}
mapDoc, err := ParseDocument(mapPath, mapText)
if err != nil {
t.Fatalf("parse real map %s: %v", mapPath, err)
}
parsed := ParseMap(entries[0].ID, mapPath, mapDoc)
if len(parsed.SourceSections) == 0 || len(parsed.PathgatePositions) == 0 || len(parsed.PassiveObjects) == 0 {
t.Fatalf("real map core fields missing: path=%s sections=%d pathgates=%d passive=%d", mapPath, len(parsed.SourceSections), len(parsed.PathgatePositions), len(parsed.PassiveObjects))
}
smokeRealDungeonGraph(t, archive)
}
func TestRealScriptPVFProfessionTutorialMetadata(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify profession tutorial metadata")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open real pvf: %v", err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipMaps: true, SkipAreas: true})
if err != nil {
t.Fatalf("load real dungeon source: %v", err)
}
professionTutorials := []struct {
id int64
path string
}{
{7110, "dungeon/Cataclysm/NewTutorial/Gunner_M_tutoral.dgn"},
{7111, "dungeon/Cataclysm/NewTutorial/7111_TheWayToCentralPark.dgn"},
{7112, "dungeon/Cataclysm/NewTutorial/Suzhouforest.dgn"},
{7113, "dungeon/Cataclysm/NewTutorial/7113_Priest_M_Tutorial.dgn"},
{7114, "dungeon/Cataclysm/NewTutorial/7114_Fighter_F_Tutorial.dgn"},
{7115, "dungeon/Cataclysm/NewTutorial/Swordman_M.dgn"},
{7116, "dungeon/Cataclysm/NewTutorial/Gunner_F.dgn"},
{7117, "dungeon/Cataclysm/NewTutorial/Mage_M.dgn"},
{7118, "dungeon/Cataclysm/NewTutorial/Tutorial_ATSwordman.dgn"},
{7119, "dungeon/Cataclysm/NewTutorial/Thief_F.dgn"},
{7124, "dungeon/Cataclysm/NewTutorial/knight_F.dgn"},
{7125, "dungeon/Cataclysm/NewTutorial/Priest_F.dgn"},
{7145, "dungeon/Cataclysm/NewTutorial/GunBlader_M_tutorial.dgn"},
}
for _, want := range professionTutorials {
dungeon, ok := table.FindDungeonPath(want.path)
if !ok {
t.Errorf("profession tutorial missing: id=%d path=%s", want.id, want.path)
continue
}
if dungeon.ID != want.id || !dungeon.Metadata.TutorialDungeon.Set || dungeon.Metadata.TutorialDungeon.Value != 1 {
t.Errorf("profession tutorial metadata mismatch: id=%d path=%s parsed_id=%d metadata=%+v",
want.id, want.path, dungeon.ID, dungeon.Metadata.TutorialDungeon)
}
}
for _, dungeonPath := range []string{
"dungeon/Impossible/tutorial_Cosmofiend.dgn",
"dungeon/Impossible/tutorial_bakalcastle.dgn",
} {
dungeon, ok := table.FindDungeonPath(dungeonPath)
if !ok {
t.Errorf("named tutorial negative fixture missing: %s", dungeonPath)
continue
}
if dungeon.Metadata.TutorialDungeon.Set {
t.Errorf("name-only tutorial was treated as a profession tutorial: path=%s metadata=%+v",
dungeonPath, dungeon.Metadata.TutorialDungeon)
}
}
}
func TestRealScriptPVFGunbladerTutorialStartRoomTopology(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify the Gunblader tutorial start room")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open real pvf: %v", err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatalf("load real worldmap source: %v", err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatalf("build real worldmap resolver: %v", err)
}
topology, err := BuildDungeonTopology(resolver, 7145, 0)
if err != nil {
t.Fatalf("build Gunblader tutorial topology: %v", err)
}
start, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || start.Map == nil {
t.Fatalf("Gunblader start room missing: %+v found=%t diagnostics=%+v", start, ok, topology.Diagnostics)
}
actionText, err := archive.ReadText("map/Cataclysm/NewTutorial/GunBlader_M/Action/70576_6801.act")
if err != nil {
t.Fatalf("read Gunblader start basic action: %v", err)
}
if start.Map.Map.ID != 70576 || len(start.Neighbors) != 1 ||
start.Neighbors[0].Direction != RoomDirectionRight ||
start.Neighbors[0].Coordinate != (RoomCoordinate{X: 1, Y: 0}) {
t.Fatalf("Gunblader start route=%+v want map 70576 with one right exit", start)
}
if len(topology.Diagnostics) != 0 {
t.Fatalf("Gunblader topology diagnostics=%+v", topology.Diagnostics)
}
if start.Map.Map.BasicAction != "Action/70576_6801.act" ||
!strings.Contains(actionText, "[END BY MOVING MAP]") {
t.Fatalf("Gunblader start action=%q lacks move-map tutorial boundary", start.Map.Map.BasicAction)
}
}
func TestRealScriptPVFTowerOfDespairSingleRoomLayout(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify tower single-room map ownership")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open real pvf: %v", err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatalf("load full real pvf source: %v", err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatalf("index real pvf resolver: %v", err)
}
if _, err := resolver.PoolCandidates(MapPoolRequest{
DungeonID: 11008,
MazeIndex: 0,
FileType: MapFileBoss,
}); !errors.Is(err, ErrMapPoolEmpty) {
t.Fatalf("real tower boss pool should be empty before start==boss fallback: %v", err)
}
topology, err := BuildDungeonLayout(resolver, 11008, 0, nil)
if err != nil {
t.Fatalf("build real tower of despair floor 1 layout: %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 ||
!strings.EqualFold(room.Map.Map.Path, "map/towerofdespair_down/15130despair001.map") {
t.Fatalf("real tower room = %+v ok=%v", room, ok)
}
}
func TestRealScriptPVFFullSourceLoad(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the full real Script.pvf source smoke test")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatalf("open full real pvf: %v", err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatalf("load full real pvf source: %v", err)
}
snapshot := table.Snapshot()
if snapshot.Maps == 0 || snapshot.Dungeons != 1450 || snapshot.Areas == 0 || snapshot.Mazes == 0 {
t.Fatalf("unexpected full real pvf snapshot: %+v", snapshot)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatalf("index full real pvf resolver: %v", err)
}
if len(resolver.byDungeon) != snapshot.Dungeons {
t.Fatalf("resolver dungeons=%d, table dungeons=%d", len(resolver.byDungeon), snapshot.Dungeons)
}
topologies := 0
topologyFailures := 0
rooms := 0
unresolvedRooms := 0
mapUnresolved := 0
mapAmbiguous := 0
startMissing := 0
startUnresolved := 0
startIsolated := 0
gridShort := 0
gridMalformed := 0
gridPairMismatch := 0
gridCodeUnknown := 0
var startIssueSamples []string
var gridIssueSamples []string
for _, dungeon := range table.dungeons {
for mazeIndex := range dungeon.Mazes {
topology, err := BuildDungeonTopology(resolver, dungeon.ID, mazeIndex)
if err != nil {
topologyFailures++
continue
}
topologies++
rooms += len(topology.rooms)
unresolvedRooms += len(topology.UnresolvedRooms())
for _, resolutionError := range topology.resolutionErrors {
switch {
case errors.Is(resolutionError, ErrMapAmbiguous):
mapAmbiguous++
case errors.Is(resolutionError, ErrMapUnresolved):
mapUnresolved++
}
}
if topology.Start == nil {
startMissing++
if len(startIssueSamples) < 5 {
startIssueSamples = append(startIssueSamples, fmt.Sprintf("%d/%d:missing", dungeon.ID, mazeIndex))
}
} else if start, ok := topology.Room(*topology.Start); !ok || start.Map == nil {
startUnresolved++
if len(startIssueSamples) < 5 {
startIssueSamples = append(startIssueSamples, fmt.Sprintf("%d/%d:%s", dungeon.ID, mazeIndex, topology.Start))
}
} else if len(start.Neighbors) == 0 && len(topology.rooms) > 1 {
startIsolated++
}
for _, diagnostic := range topology.Diagnostics {
switch diagnostic.Code {
case "maze_grid_short", "maze_grid_rows_short", "maze_grid_row_short":
gridShort++
case "maze_grid_malformed":
gridMalformed++
case "maze_grid_pair_mismatch":
gridPairMismatch++
case "maze_grid_code_unknown":
gridCodeUnknown++
}
if strings.HasPrefix(diagnostic.Code, "maze_grid_") && len(gridIssueSamples) < 5 {
gridIssueSamples = append(
gridIssueSamples,
fmt.Sprintf("%d/%d:%s:%s", dungeon.ID, mazeIndex, diagnostic.Code, diagnostic.Message),
)
}
}
}
}
if topologies == 0 || rooms == 0 {
t.Fatalf("real PVF yielded no dungeon topology: built=%d failures=%d rooms=%d", topologies, topologyFailures, rooms)
}
t.Logf(
"full real PVF source: %+v resolver_dungeons=%d topologies=%d topology_failures=%d rooms=%d unresolved_rooms=%d map_unresolved=%d map_ambiguous=%d start_missing=%d start_unresolved=%d start_isolated=%d grid_short=%d grid_malformed=%d grid_pair_mismatch=%d grid_code_unknown=%d",
snapshot, len(resolver.byDungeon), topologies, topologyFailures, rooms, unresolvedRooms,
mapUnresolved, mapAmbiguous, startMissing, startUnresolved, startIsolated,
gridShort, gridMalformed, gridPairMismatch, gridCodeUnknown,
)
t.Logf("real PVF topology issue samples: starts=%v grids=%v", startIssueSamples, gridIssueSamples)
}
func smokeRealDungeonGraph(t *testing.T, archive *platformpvf.Archive) {
t.Helper()
dungeonTable, err := LoadSource(context.Background(), archive, Options{SkipMaps: true, SkipAreas: true})
if err != nil {
t.Fatalf("load real dungeon source: %v", err)
}
mapListText, err := archive.ReadText(DefaultMapList)
if err != nil {
t.Fatalf("read real map list for dungeon graph: %v", err)
}
mapListDoc, err := dnfpvf.Parse(DefaultMapList, mapListText)
if err != nil {
t.Fatalf("parse real map list for dungeon graph: %v", err)
}
mapPaths := make(map[int64]string)
for _, entry := range dnfpvf.ParseList(mapListDoc) {
mapPaths[entry.ID] = prefixedArchivePath("map", entry.Path)
}
parsedDocuments := len(dungeonTable.dungeons)
extensionNames := make(map[string]struct{})
randomizedScripts := 0
randomizedObjects := 0
clearConditions := 0
var selected Dungeon
selectedMaze := -1
for _, dungeon := range dungeonTable.dungeons {
dungeonPath := dungeon.Path
if len(dungeon.Extensions) < len(dungeon.UnknownSections) {
t.Fatalf("real dungeon extension/raw mismatch: path=%s unknown=%d extensions=%d", dungeonPath, len(dungeon.UnknownSections), len(dungeon.Extensions))
}
for _, extension := range dungeon.Extensions {
extensionNames[sectionKey(extension.Name)] = struct{}{}
}
for mazePos, maze := range dungeon.Mazes {
if len(maze.Extensions) < len(maze.UnknownSections) {
t.Fatalf("real maze extension/raw mismatch: path=%s maze=%d unknown=%d extensions=%d", dungeonPath, mazePos, len(maze.UnknownSections), len(maze.Extensions))
}
for _, extension := range maze.Extensions {
extensionNames[sectionKey(extension.Name)] = struct{}{}
}
randomizedScripts += len(maze.RandomizedObjects)
clearConditions += len(maze.ClearConditions)
for _, script := range maze.RandomizedObjects {
randomizedObjects += len(script.Objects)
}
if selectedMaze < 0 && len(maze.MapSpecifications) > 0 && allSpecificationMapsPresent(maze, mapPaths) {
selected = dungeon
selectedMaze = mazePos
}
}
}
if parsedDocuments == 0 || selectedMaze < 0 {
t.Fatalf("real dungeon graph unavailable: parsed=%d selected_maze=%d", parsedDocuments, selectedMaze)
}
if len(extensionNames) == 0 {
t.Fatal("real PVF contains no parsed C#-missing dungeon extensions")
}
if randomizedScripts == 0 || randomizedObjects == 0 || clearConditions == 0 {
t.Fatalf(
"real complex dungeon structures missing: randomized_scripts=%d randomized_objects=%d clear_conditions=%d",
randomizedScripts, randomizedObjects, clearConditions,
)
}
selected.Mazes = []Maze{selected.Mazes[selectedMaze]}
selected.Mazes[0].Index = 0
maps := loadSmokeSpecificationMaps(t, archive, selected.Mazes[0], mapPaths)
table := smokeResolverTable(selected, maps)
resolver, err := NewResolver(table)
if err != nil {
t.Fatalf("index real dungeon graph: %v", err)
}
resolved := false
for _, specification := range selected.Mazes[0].MapSpecifications {
request := ResolveRequest{
DungeonID: selected.ID, MazeIndex: 0,
X: specification.Coordinate.X, Y: specification.Coordinate.Y,
}
if _, err := resolver.Resolve(request); err == nil {
resolved = true
break
}
}
if !resolved {
t.Fatalf("real map specifications did not yield one deterministic room: dungeon=%d path=%s", selected.ID, selected.Path)
}
topology, err := BuildDungeonTopology(resolver, selected.ID, 0)
if err != nil {
t.Fatalf("build real dungeon topology: dungeon=%d path=%s: %v", selected.ID, selected.Path, err)
}
run, err := NewDungeonRun(topology)
if err != nil {
t.Fatalf("start real dungeon run: dungeon=%d path=%s: %v", selected.ID, selected.Path, err)
}
current, ok := run.CurrentRoom()
if !ok || current.Map == nil {
t.Fatalf("real dungeon start room unresolved: dungeon=%d room=%+v", selected.ID, current)
}
if err := run.MarkCurrentRoomCleared(); err != nil {
t.Fatalf("clear real dungeon start room: %v", err)
}
moved := false
for _, neighbor := range current.Neighbors {
if neighbor.Coordinate.X < 0 || neighbor.Coordinate.X > 255 || neighbor.Coordinate.Y < 0 || neighbor.Coordinate.Y > 255 {
continue
}
room, ok := topology.Room(neighbor.Coordinate)
if !ok || room.Map == nil {
continue
}
if _, err := run.MoveByte(byte(neighbor.Coordinate.X), byte(neighbor.Coordinate.Y)); err != nil {
t.Fatalf("move through real dungeon topology: %v", err)
}
moved = true
break
}
if !moved {
t.Fatalf("real dungeon start has no EXE-coordinate compatible resolved neighbor: dungeon=%d room=%s", selected.ID, current.Coordinate)
}
t.Logf(
"real dungeon alignment: parsed=%d parse_failures=0 extension_tags=%d randomized_scripts=%d randomized_objects=%d clear_conditions=%d selected_dungeon=%d selected_path=%s maze_specs=%d loaded_maps=%d topology_rooms=%d unresolved_rooms=%d moved_to=%s",
parsedDocuments, len(extensionNames), randomizedScripts, randomizedObjects, clearConditions, selected.ID, selected.Path,
len(selected.Mazes[0].MapSpecifications), len(maps), len(topology.rooms), len(topology.UnresolvedRooms()), run.Snapshot().Current,
)
}
func allSpecificationMapsPresent(maze Maze, paths map[int64]string) bool {
for _, specification := range maze.MapSpecifications {
for _, mapID := range specification.MapIDs {
if paths[mapID] == "" {
return false
}
}
}
return true
}
func loadSmokeSpecificationMaps(t *testing.T, archive *platformpvf.Archive, maze Maze, paths map[int64]string) []Map {
t.Helper()
seen := make(map[int64]struct{})
var maps []Map
for _, specification := range maze.MapSpecifications {
for _, mapID := range specification.MapIDs {
if _, ok := seen[mapID]; ok {
continue
}
seen[mapID] = struct{}{}
mapPath := paths[mapID]
text, err := archive.ReadText(mapPath)
if err != nil {
t.Fatalf("read real specification map %d %s: %v", mapID, mapPath, err)
}
doc, err := ParseDocument(mapPath, text)
if err != nil {
t.Fatalf("parse real specification map %d %s: %v", mapID, mapPath, err)
}
maps = append(maps, ParseMap(mapID, mapPath, doc))
}
}
return maps
}
func smokeResolverTable(dungeon Dungeon, maps []Map) *Table {
table := &Table{
maps: maps, dungeons: []Dungeon{dungeon},
mapByID: make(map[int64]int), mapByPath: make(map[string]int),
dungeonByID: map[int64]int{dungeon.ID: 0}, dungeonByPath: map[string]int{pathKey(dungeon.Path): 0},
}
for pos, mapValue := range maps {
table.mapByID[mapValue.ID] = pos
table.mapByPath[pathKey(mapValue.Path)] = pos
}
return table
}
func prefixedArchivePath(prefix, value string) string {
value = strings.TrimPrefix(strings.ReplaceAll(value, "\\", "/"), "/")
if !strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)+"/") {
return prefix + "/" + value
}
return value
}
@@ -0,0 +1,191 @@
package worldmap
import (
"context"
"errors"
"fmt"
"path"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
var ErrSourceRequired = errors.New("dnf worldmap source is required")
func LoadSource(ctx context.Context, source dnfpvf.Source, options Options) (*Table, error) {
if source == nil {
return nil, ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
table := &Table{
mapByID: make(map[int64]int), mapByPath: make(map[string]int),
areaByID: make(map[int64]int), areaByPath: make(map[string]int), areaByDungeon: make(map[int64]int),
dungeonByID: make(map[int64]int), dungeonByPath: make(map[string]int),
}
if !options.SkipMaps {
listPath := options.MapListPath
if listPath == "" {
listPath = DefaultMapList
}
entries, err := sourceList(source, listPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
docPath, doc, err := sourceDocument(source, listPath, entry.Path)
if err != nil {
return nil, err
}
parsed, err := parseMapWithImports(entry.ID, docPath, doc, func(candidate string) (string, *dnfpvf.Document, error) {
text, err := source.ReadText(candidate)
if err != nil {
return "", nil, err
}
importDoc, err := ParseDocument(candidate, text)
if err != nil {
return "", nil, err
}
return candidate, importDoc, nil
})
if err != nil {
return nil, fmt.Errorf("load dnf worldmap map %q: %w", docPath, err)
}
if options.CompactRuntime {
compactMapForRuntime(&parsed)
}
addMapToTable(table, parsed)
}
}
if !options.SkipDungeons {
listPath := options.DungeonListPath
if listPath == "" {
listPath = DefaultDungeonList
}
entries, err := sourceList(source, listPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
docPath, doc, err := sourceDocument(source, listPath, entry.Path)
if err != nil {
return nil, err
}
parsed := ParseDungeon(entry.ID, docPath, doc)
if options.CompactRuntime {
compactDungeonForRuntime(&parsed)
}
addDungeonToTable(table, parsed)
}
}
if !options.SkipAreas {
listPath := options.WorldMapListPath
if listPath == "" {
listPath = DefaultWorldMapList
}
entries, err := sourceList(source, listPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
docPath, doc, err := sourceDocument(source, listPath, entry.Path)
if err != nil {
return nil, err
}
parsed := ParseArea(entry.ID, docPath, doc)
if options.CompactRuntime {
compactAreaForRuntime(&parsed)
}
addAreaToTable(table, parsed)
}
}
return table, nil
}
func sourceList(source dnfpvf.Source, listPath string) ([]dnfpvf.ListEntry, error) {
text, err := source.ReadText(listPath)
if err != nil {
return nil, fmt.Errorf("read dnf worldmap list %q: %w", listPath, err)
}
doc, err := ParseDocument(listPath, text)
if err != nil {
return nil, err
}
entries := dnfpvf.ParseList(doc)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
return entries, nil
}
func sourceDocument(source dnfpvf.Source, listPath, reference string) (string, *dnfpvf.Document, error) {
docPath := resolveSourceReference(listPath, reference)
text, err := source.ReadText(docPath)
if err != nil {
return "", nil, fmt.Errorf("read dnf worldmap document %q: %w", docPath, err)
}
doc, err := ParseDocument(docPath, text)
if err != nil {
return "", nil, err
}
return docPath, doc, nil
}
func resolveSourceReference(listPath, reference string) string {
reference = strings.TrimSpace(strings.ReplaceAll(reference, "\\", "/"))
reference = strings.TrimPrefix(reference, "./")
reference = strings.TrimPrefix(reference, "/")
listDirectory := strings.TrimSuffix(path.Dir(strings.ReplaceAll(listPath, "\\", "/")), "/")
if listDirectory == "." || listDirectory == "" {
return reference
}
if pathKey(reference) == pathKey(listDirectory) || strings.HasPrefix(pathKey(reference), pathKey(listDirectory)+"/") {
return reference
}
return path.Join(listDirectory, reference)
}
func addMapToTable(table *Table, parsed Map) {
pos := len(table.maps)
table.maps = append(table.maps, parsed)
if _, exists := table.mapByID[parsed.ID]; !exists {
table.mapByID[parsed.ID] = pos
}
table.mapByPath[pathKey(parsed.Path)] = pos
}
func addDungeonToTable(table *Table, parsed Dungeon) {
pos := len(table.dungeons)
table.dungeons = append(table.dungeons, parsed)
if _, exists := table.dungeonByID[parsed.ID]; !exists {
table.dungeonByID[parsed.ID] = pos
}
table.dungeonByPath[pathKey(parsed.Path)] = pos
}
func addAreaToTable(table *Table, parsed Area) {
pos := len(table.areas)
table.areas = append(table.areas, parsed)
if _, exists := table.areaByID[parsed.ID]; !exists {
table.areaByID[parsed.ID] = pos
}
table.areaByPath[pathKey(parsed.Path)] = pos
for _, dungeon := range parsed.Dungeons {
if dungeon.DungeonID <= 0 {
continue
}
if _, exists := table.areaByDungeon[dungeon.DungeonID]; !exists {
table.areaByDungeon[dungeon.DungeonID] = pos
}
}
}
@@ -0,0 +1,74 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// Time Rift has level-band variants. Keep every route on the real-PVF entry
// path: a regression in one of the rare special routes must never turn
// SELECT_DUNGEON into a client spinner again.
func TestRealScriptPVFTimeRiftAllMazesBuildEntryLayouts(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify Time Rift 7527")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatal(err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
for _, want := range []struct {
dungeonID int64
questID int64
mazes int
}{
{dungeonID: 7527, questID: 10104, mazes: 10},
{dungeonID: 7539, questID: 10110, mazes: 7},
} {
dungeon, found := table.FindDungeon(want.dungeonID)
if !found {
t.Fatalf("Time Rift dungeon %d is missing", want.dungeonID)
}
if !dungeon.Metadata.InfernoDungeon || len(dungeon.Metadata.InfernoQuestIDs) != 1 ||
dungeon.Metadata.InfernoQuestIDs[0] != want.questID {
t.Fatalf("Time Rift dungeon=%d inferno metadata=%+v", want.dungeonID, dungeon.Metadata)
}
if len(dungeon.Mazes) != want.mazes {
t.Fatalf("Time Rift dungeon=%d mazes=%d want %d", want.dungeonID, len(dungeon.Mazes), want.mazes)
}
for mazeIndex, maze := range dungeon.Mazes {
if maze.Start == nil || maze.Boss == nil || !maze.ChanceRate.Set || maze.ChanceRate.Value <= 0 {
t.Fatalf("dungeon=%d maze=%d entry metadata=%+v", want.dungeonID, mazeIndex, maze)
}
topology, err := BuildDungeonLayout(resolver, dungeon.ID, mazeIndex, func(choice DungeonMapChoice) (int64, error) {
return choice.Candidates[0].ID, nil
})
if err != nil {
t.Fatalf("dungeon=%d maze=%d layout: %v", want.dungeonID, mazeIndex, err)
}
start, ok := topology.Room(RoomCoordinate{X: maze.Start.X, Y: maze.Start.Y})
if !ok || start.Map == nil || len(start.Map.Map.Monsters) == 0 {
t.Fatalf("dungeon=%d maze=%d start=%+v found=%t", want.dungeonID, mazeIndex, start, ok)
}
boss, ok := topology.Room(RoomCoordinate{X: maze.Boss.X, Y: maze.Boss.Y})
if !ok || boss.Map == nil || !boss.Boss {
t.Fatalf("dungeon=%d maze=%d boss=%+v found=%t", want.dungeonID, mazeIndex, boss, ok)
}
if _, err := NewDungeonRun(topology); err != nil {
t.Fatalf("dungeon=%d maze=%d create run: %v", want.dungeonID, mazeIndex, err)
}
}
}
}
@@ -0,0 +1,789 @@
package worldmap
import (
"errors"
"fmt"
"strings"
"sync"
)
const tournamentRoundCount = 4
var (
ErrTournamentDefinitionInvalid = errors.New("dnf tournament definition is invalid")
ErrTournamentRandomInvalid = errors.New("dnf tournament random source returned an invalid index")
ErrTournamentSequenceUnbound = errors.New("dnf tournament actor sequence is not bound")
)
const (
tournamentCardTypeCount = 2
tournamentCardsPerType = 2
)
// TournamentReward is the frozen face of one Yellow Dragon reward card.
// ItemID==0 represents a gold/empty wire tuple; Empty disambiguates the
// zero-value empty card from a real gold card with a zero amount.
type TournamentReward struct {
ItemID uint32
Count uint32
Gold bool
Empty bool
}
// TournamentRewardPlan mirrors the current client card topology: two cards
// in the upper group and, for champions only, two cards in the lower group.
// The plan is created once after the terminal actor death and then only moves
// through reservation/delivery states. It is intentionally independent of
// packet encoding and inventory persistence.
type TournamentRewardPlan struct {
CompletedRounds byte
Experience uint32
Champion bool
Upper [tournamentCardsPerType]TournamentReward
Lower [tournamentCardsPerType]TournamentReward
selected [tournamentCardTypeCount]int8
delivered [tournamentCardTypeCount]bool
}
func (p *TournamentRewardPlan) initSelection() {
if p == nil {
return
}
for index := range p.selected {
p.selected[index] = -1
}
}
func (p TournamentRewardPlan) CardCount(side byte) byte {
if side == 0 {
return tournamentCardsPerType
}
if side == 1 && p.Champion {
return tournamentCardsPerType
}
return 0
}
func (p TournamentRewardPlan) Card(side, index byte) (TournamentReward, bool) {
if index >= tournamentCardsPerType || index >= p.CardCount(side) {
return TournamentReward{}, false
}
if side == 0 {
return p.Upper[index], true
}
return p.Lower[index], true
}
// SelectedCard reports the committed card for one visible reward group.
// A reserved but not yet delivered selection is intentionally not projected
// to the client as owned.
func (p TournamentRewardPlan) SelectedCard(side byte) (byte, bool) {
if side >= tournamentCardTypeCount || !p.delivered[side] || p.selected[side] < 0 {
return 0, false
}
index := byte(p.selected[side])
if index >= p.CardCount(side) {
return 0, false
}
return index, true
}
// ReserveSelection performs the S4A12-style two-phase selection transition.
// The boolean replay result is true only for an already delivered identical
// selection, allowing a retry to be acknowledged without a second grant.
func (p *TournamentRewardPlan) ReserveSelection(side, index byte) (TournamentReward, bool, bool) {
if p == nil || index >= p.CardCount(side) || side >= tournamentCardTypeCount {
return TournamentReward{}, false, false
}
if p.delivered[side] {
// A retry is idempotent only when it repeats the exact card that was
// committed. A different card index must be rejected instead of being
// acknowledged with a mismatched reward row.
if p.selected[side] != int8(index) {
return TournamentReward{}, false, false
}
reward, ok := p.Card(side, index)
return reward, false, ok
}
if p.selected[side] >= 0 {
return TournamentReward{}, false, false
}
p.selected[side] = int8(index)
reward, _ := p.Card(side, index)
return reward, true, false
}
func (p *TournamentRewardPlan) CommitSelection(side, index byte) bool {
if p == nil || side >= tournamentCardTypeCount || index >= p.CardCount(side) || p.selected[side] != int8(index) || p.delivered[side] {
return false
}
p.delivered[side] = true
return true
}
func (p *TournamentRewardPlan) RollbackSelection(side, index byte) bool {
if p == nil || side >= tournamentCardTypeCount || index >= p.CardCount(side) || p.selected[side] != int8(index) || p.delivered[side] {
return false
}
p.selected[side] = -1
return true
}
func (p TournamentRewardPlan) IsSelectionComplete() bool {
if p.selected[0] < 0 || !p.delivered[0] {
return false
}
return !p.Champion || (p.selected[1] >= 0 && p.delivered[1])
}
func (p TournamentRewardPlan) Cardless() bool {
for side := byte(0); side < tournamentCardTypeCount; side++ {
for index := byte(0); index < p.CardCount(side); index++ {
reward, ok := p.Card(side, index)
if !ok || !reward.Empty {
return false
}
}
}
return true
}
func (p *TournamentRewardPlan) commitCardless() bool {
if p == nil || !p.Cardless() {
return false
}
if p.IsSelectionComplete() {
return true
}
for side := byte(0); side < tournamentCardTypeCount; side++ {
if p.CardCount(side) == 0 {
continue
}
p.selected[side] = 0
p.delivered[side] = true
}
return p.IsSelectionComplete()
}
type TournamentActorKind byte
const (
TournamentActorMonster TournamentActorKind = iota
TournamentActorAICharacter
)
type TournamentActor struct {
Kind TournamentActorKind `json:"kind"`
Code uint32 `json:"code"`
Strength uint16 `json:"strength"`
Name string `json:"name"`
Level byte `json:"level"`
Type byte `json:"type"`
}
type TournamentTeam struct {
Position byte `json:"position"`
Members []TournamentActor `json:"members"`
Player bool `json:"player"`
}
type TournamentRound struct {
Number byte `json:"number"`
Teams []TournamentTeam `json:"teams"`
}
type TournamentDefinition struct {
DungeonID uint32
MapID uint32
DifficultyLevel byte
PartyCount byte
RoundFatigue int64
GoldRate int64
Experience map[byte]uint32
ResultCards map[byte]TournamentResultCard
RewardItems []TournamentRewardItemRate
Candidates []TournamentActor
StartAreas []TournamentStartArea
}
type TournamentActorProjector func(candidate TournamentEnemyCandidate, basisLevel byte) (level byte, actorType byte, err error)
func (t *Table) TournamentDefinition(dungeonID, mapID int64, partyCount int, projector TournamentActorProjector) (TournamentDefinition, error) {
dungeon, ok := t.FindDungeon(dungeonID)
if !ok {
return TournamentDefinition{}, fmt.Errorf("%w: dungeon=%d is missing", ErrTournamentDefinitionInvalid, dungeonID)
}
tournamentMap, ok := t.FindMap(mapID)
if !ok {
return TournamentDefinition{}, fmt.Errorf("%w: map=%d is missing", ErrTournamentDefinitionInvalid, mapID)
}
return buildTournamentDefinition(dungeon, tournamentMap, partyCount, projector)
}
func buildTournamentDefinition(dungeon Dungeon, tournamentMap Map, partyCount int, projector TournamentActorProjector) (TournamentDefinition, error) {
if dungeon.Metadata.Flags == nil || !dungeon.Metadata.Flags["tournament dungeon"] {
return TournamentDefinition{}, fmt.Errorf("%w: dungeon=%d is not marked tournament", ErrTournamentDefinitionInvalid, dungeon.ID)
}
if dungeon.ID <= 0 || dungeon.ID > int64(^uint32(0)) || tournamentMap.ID <= 0 || tournamentMap.ID > int64(^uint32(0)) {
return TournamentDefinition{}, fmt.Errorf("%w: dungeon=%d map=%d exceeds wire range", ErrTournamentDefinitionInvalid, dungeon.ID, tournamentMap.ID)
}
if !tournamentMap.DungeonID.Set || tournamentMap.DungeonID.Value != dungeon.ID {
return TournamentDefinition{}, fmt.Errorf("%w: map=%d belongs to dungeon=%d", ErrTournamentDefinitionInvalid, tournamentMap.ID, tournamentMap.DungeonID.Value)
}
if tournamentMap.TournamentMalformed || partyCount < 1 || partyCount > 2 {
return TournamentDefinition{}, fmt.Errorf("%w: party_count=%d malformed_map=%t", ErrTournamentDefinitionInvalid, partyCount, tournamentMap.TournamentMalformed)
}
if !dungeon.Metadata.LimitPartyCount.Set || dungeon.Metadata.LimitPartyCount.Value != int64(partyCount) {
return TournamentDefinition{}, fmt.Errorf("%w: party_count=%d limit=%d", ErrTournamentDefinitionInvalid, partyCount, dungeon.Metadata.LimitPartyCount.Value)
}
if !dungeon.Metadata.BasisLevel.Set || dungeon.Metadata.BasisLevel.Value < 1 || dungeon.Metadata.BasisLevel.Value > 0xff {
return TournamentDefinition{}, fmt.Errorf("%w: basis_level=%d", ErrTournamentDefinitionInvalid, dungeon.Metadata.BasisLevel.Value)
}
basisLevel := byte(dungeon.Metadata.BasisLevel.Value)
definition := TournamentDefinition{
DungeonID: uint32(dungeon.ID), MapID: uint32(tournamentMap.ID), PartyCount: byte(partyCount),
DifficultyLevel: basisLevel, RoundFatigue: dungeon.Metadata.TournamentRoundFatigue,
GoldRate: dungeon.Metadata.TournamentGoldRate, Experience: make(map[byte]uint32, 5),
ResultCards: make(map[byte]TournamentResultCard, 6),
RewardItems: append([]TournamentRewardItemRate(nil), dungeon.Metadata.TournamentRewardItemRates...),
StartAreas: append([]TournamentStartArea(nil), tournamentMap.TournamentStartAreas...),
}
for _, row := range dungeon.Metadata.TournamentClearRewardExp {
if row.CompletedRounds < 0 || row.CompletedRounds > tournamentRoundCount || row.Experience < 0 || row.Experience > int64(^uint32(0)) {
return TournamentDefinition{}, fmt.Errorf("%w: reward experience row=%+v", ErrTournamentDefinitionInvalid, row)
}
key := byte(row.CompletedRounds)
if _, exists := definition.Experience[key]; exists {
return TournamentDefinition{}, fmt.Errorf("%w: duplicate reward experience round=%d", ErrTournamentDefinitionInvalid, key)
}
definition.Experience[key] = uint32(row.Experience)
}
for round := byte(0); round <= tournamentRoundCount; round++ {
if _, ok := definition.Experience[round]; !ok {
return TournamentDefinition{}, fmt.Errorf("%w: reward experience round=%d is missing", ErrTournamentDefinitionInvalid, round)
}
}
for _, card := range dungeon.Metadata.TournamentResultCards {
if card.ResultKey < 0 || card.ResultKey > 5 || card.GoldWeight < 0 || card.ItemWeight < 0 || card.EmptyWeight < 0 || card.GoldWeight+card.ItemWeight+card.EmptyWeight <= 0 {
return TournamentDefinition{}, fmt.Errorf("%w: result card=%+v", ErrTournamentDefinitionInvalid, card)
}
key := byte(card.ResultKey)
if _, exists := definition.ResultCards[key]; exists {
return TournamentDefinition{}, fmt.Errorf("%w: duplicate result card=%d", ErrTournamentDefinitionInvalid, key)
}
definition.ResultCards[key] = card
}
for key := byte(0); key <= 5; key++ {
if _, ok := definition.ResultCards[key]; !ok {
return TournamentDefinition{}, fmt.Errorf("%w: result card=%d is missing", ErrTournamentDefinitionInvalid, key)
}
}
if len(definition.RewardItems) == 0 || definition.RoundFatigue < 0 || definition.GoldRate <= 0 {
return TournamentDefinition{}, fmt.Errorf("%w: reward metadata is incomplete", ErrTournamentDefinitionInvalid)
}
for _, reward := range definition.RewardItems {
if reward.ItemID <= 0 || reward.Weight <= 0 || reward.Count <= 0 {
return TournamentDefinition{}, fmt.Errorf("%w: reward item=%+v", ErrTournamentDefinitionInvalid, reward)
}
}
for _, candidate := range tournamentMap.TournamentEnemies {
if candidate.PartyCount != int64(partyCount) {
continue
}
if candidate.Code <= 0 || candidate.Code > int64(^uint32(0)) || candidate.Strength < 0 || candidate.Strength > 0xffff || strings.TrimSpace(candidate.Name) == "" {
return TournamentDefinition{}, fmt.Errorf("%w: actor=%+v", ErrTournamentDefinitionInvalid, candidate)
}
actor := TournamentActor{Code: uint32(candidate.Code), Strength: uint16(candidate.Strength), Name: candidate.Name, Level: basisLevel}
switch strings.ToLower(strings.TrimSpace(candidate.Kind)) {
case "[monster]":
actor.Kind = TournamentActorMonster
case "[apc]":
if projector == nil {
return TournamentDefinition{}, fmt.Errorf("%w: APC code=%d requires a projector", ErrTournamentDefinitionInvalid, candidate.Code)
}
level, actorType, err := projector(candidate, basisLevel)
if err != nil {
return TournamentDefinition{}, fmt.Errorf("%w: project APC code=%d: %v", ErrTournamentDefinitionInvalid, candidate.Code, err)
}
actor.Kind, actor.Level, actor.Type = TournamentActorAICharacter, level, actorType
default:
return TournamentDefinition{}, fmt.Errorf("%w: actor kind=%q", ErrTournamentDefinitionInvalid, candidate.Kind)
}
definition.Candidates = append(definition.Candidates, actor)
}
if len(definition.Candidates) < 15*partyCount {
return TournamentDefinition{}, fmt.Errorf("%w: candidates=%d required=%d", ErrTournamentDefinitionInvalid, len(definition.Candidates), 15*partyCount)
}
startCount := 0
for _, area := range definition.StartAreas {
if area.PartyCount == int64(partyCount) {
startCount++
}
}
if startCount < 2 {
return TournamentDefinition{}, fmt.Errorf("%w: start areas=%d required=2", ErrTournamentDefinitionInvalid, startCount)
}
return definition, nil
}
type TournamentRandom func(exclusiveMaximum int) (int, error)
type TournamentTerminalResult byte
const (
TournamentActive TournamentTerminalResult = iota
TournamentChampion
TournamentEliminated
)
type TournamentDeathDisposition byte
const (
TournamentDeathRejected TournamentDeathDisposition = iota
TournamentDeathDuplicate
TournamentDeathAccepted
TournamentRoundCompleted
TournamentCompleted
)
type TournamentDeathTransition struct {
Disposition TournamentDeathDisposition
CompletedRound byte
CurrentRound byte
}
type TournamentRuntime struct {
mu sync.Mutex
Definition TournamentDefinition
Rounds []TournamentRound
PathActors []TournamentActor
firstSequence uint16
currentRound byte
completedRounds byte
terminal TournamentTerminalResult
acceptedEvents map[string]uint16
acceptedSequences map[uint16]struct{}
rewardPlan *TournamentRewardPlan
}
// PrepareRewardPlan freezes the PVF-selected card faces exactly once after a
// tournament becomes terminal. The caller supplies the already-authoritative
// gold/experience amounts because those values belong to the bridge's reward
// and persistence layers, not to the world-map parser.
func (r *TournamentRuntime) PrepareRewardPlan(experience, gold uint32, random TournamentRandom) (TournamentRewardPlan, bool, error) {
if r == nil || random == nil {
return TournamentRewardPlan{}, false, fmt.Errorf("%w: reward random source is missing", ErrTournamentDefinitionInvalid)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.terminal == TournamentActive {
return TournamentRewardPlan{}, false, fmt.Errorf("%w: tournament is not terminal", ErrTournamentDefinitionInvalid)
}
if r.rewardPlan != nil {
return *r.rewardPlan, false, nil
}
plan := TournamentRewardPlan{
CompletedRounds: r.completedRounds,
Experience: experience,
Champion: r.terminal == TournamentChampion,
}
plan.initSelection()
upperKey := r.completedRounds
for index := byte(0); index < tournamentCardsPerType; index++ {
reward, err := tournamentRollReward(r.Definition, upperKey, gold, random)
if err != nil {
return TournamentRewardPlan{}, false, err
}
plan.Upper[index] = reward
}
if plan.Champion {
for index := byte(0); index < tournamentCardsPerType; index++ {
reward, err := tournamentRollReward(r.Definition, 5, gold, random)
if err != nil {
return TournamentRewardPlan{}, false, err
}
plan.Lower[index] = reward
}
}
r.rewardPlan = &plan
return plan, true, nil
}
func (r *TournamentRuntime) RewardPlanSnapshot() (TournamentRewardPlan, bool) {
if r == nil {
return TournamentRewardPlan{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.rewardPlan == nil {
return TournamentRewardPlan{}, false
}
return *r.rewardPlan, true
}
func (r *TournamentRuntime) ReserveRewardSelection(side, index byte) (TournamentReward, bool, bool) {
if r == nil {
return TournamentReward{}, false, false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.rewardPlan == nil {
return TournamentReward{}, false, false
}
return r.rewardPlan.ReserveSelection(side, index)
}
func (r *TournamentRuntime) CommitRewardSelection(side, index byte) bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.rewardPlan != nil && r.rewardPlan.CommitSelection(side, index)
}
func (r *TournamentRuntime) RollbackRewardSelection(side, index byte) bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.rewardPlan != nil && r.rewardPlan.RollbackSelection(side, index)
}
func (r *TournamentRuntime) RewardSelectionComplete() bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.rewardPlan != nil && r.rewardPlan.IsSelectionComplete()
}
// CommitCardlessRewardPlan closes a PVF reward plan whose visible faces are
// all empty. The current client represents this as zero card groups in op388,
// so there is no op458 request that could otherwise cross the delivery gate.
func (r *TournamentRuntime) CommitCardlessRewardPlan() bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.rewardPlan != nil && r.rewardPlan.commitCardless()
}
// RewardSideSelectionComplete reports whether one visible card group has
// already crossed the durable delivery boundary. It is used by timeout
// coordinators to fill only client selections that never arrived.
func (r *TournamentRuntime) RewardSideSelectionComplete(side byte) bool {
if r == nil || side >= tournamentCardTypeCount {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.rewardPlan != nil && r.rewardPlan.delivered[side]
}
func tournamentRollReward(definition TournamentDefinition, resultKey byte, gold uint32, random TournamentRandom) (TournamentReward, error) {
card, ok := definition.ResultCards[resultKey]
if !ok {
return TournamentReward{}, fmt.Errorf("%w: reward result key=%d is missing", ErrTournamentDefinitionInvalid, resultKey)
}
total := card.GoldWeight + card.ItemWeight + card.EmptyWeight
if total <= 0 {
return TournamentReward{}, fmt.Errorf("%w: reward result key=%d has no weight", ErrTournamentDefinitionInvalid, resultKey)
}
roll, err := tournamentRandomIndex(random, int(total))
if err != nil {
return TournamentReward{}, err
}
if int64(roll) < card.GoldWeight {
return TournamentReward{Gold: true, Count: gold}, nil
}
if int64(roll) < card.GoldWeight+card.ItemWeight {
reward, err := tournamentRollRewardItem(definition.RewardItems, random)
if err != nil {
return TournamentReward{}, err
}
return reward, nil
}
return TournamentReward{Empty: true}, nil
}
func tournamentRollRewardItem(items []TournamentRewardItemRate, random TournamentRandom) (TournamentReward, error) {
var total int64
for _, item := range items {
if item.ItemID <= 0 || item.Weight <= 0 || item.Count <= 0 {
continue
}
total += item.Weight
}
if total <= 0 || total > int64(^uint(0)>>1) {
return TournamentReward{}, fmt.Errorf("%w: reward item rates are empty", ErrTournamentDefinitionInvalid)
}
roll, err := tournamentRandomIndex(random, int(total))
if err != nil {
return TournamentReward{}, err
}
var cumulative int64
for _, item := range items {
if item.ItemID <= 0 || item.Weight <= 0 || item.Count <= 0 {
continue
}
cumulative += item.Weight
if int64(roll) < cumulative {
return TournamentReward{ItemID: uint32(item.ItemID), Count: uint32(item.Count)}, nil
}
}
return TournamentReward{}, fmt.Errorf("%w: reward item roll did not resolve", ErrTournamentDefinitionInvalid)
}
func NewTournamentRuntime(definition TournamentDefinition, random TournamentRandom) (*TournamentRuntime, error) {
if random == nil {
return nil, fmt.Errorf("%w: random source is nil", ErrTournamentDefinitionInvalid)
}
partyCount := int(definition.PartyCount)
if partyCount < 1 || partyCount > 2 || len(definition.Candidates) < 15*partyCount {
return nil, fmt.Errorf("%w: party=%d candidates=%d", ErrTournamentDefinitionInvalid, partyCount, len(definition.Candidates))
}
candidates := append([]TournamentActor(nil), definition.Candidates...)
if err := shuffleTournamentActors(candidates, random); err != nil {
return nil, err
}
npcTeams := make([]TournamentTeam, 15)
for index := range npcTeams {
members := append([]TournamentActor(nil), candidates[index*partyCount:(index+1)*partyCount]...)
npcTeams[index] = TournamentTeam{Members: members}
}
for i := 1; i < len(npcTeams); i++ {
for j := i; j > 0 && tournamentTeamStrength(npcTeams[j]) < tournamentTeamStrength(npcTeams[j-1]); j-- {
npcTeams[j], npcTeams[j-1] = npcTeams[j-1], npcTeams[j]
}
}
playerPosition, err := tournamentRandomIndex(random, 16)
if err != nil {
return nil, err
}
opponentPosition := playerPosition ^ 1
firstRound := make([]TournamentTeam, 16)
firstRound[playerPosition] = TournamentTeam{Position: byte(playerPosition), Members: make([]TournamentActor, partyCount), Player: true}
firstRound[opponentPosition] = tournamentTeamAt(npcTeams[0], opponentPosition)
remaining := append([]TournamentTeam(nil), npcTeams[1:]...)
if err := shuffleTournamentTeams(remaining, random); err != nil {
return nil, err
}
remainingIndex := 0
for position := range firstRound {
if position == playerPosition || position == opponentPosition {
continue
}
firstRound[position] = tournamentTeamAt(remaining[remainingIndex], position)
remainingIndex++
}
rounds := []TournamentRound{{Number: 1, Teams: firstRound}}
previous := firstRound
for round := byte(2); round <= tournamentRoundCount; round++ {
nextRound := make([]TournamentTeam, len(previous)/2)
for match := range nextRound {
left, right := previous[match*2], previous[match*2+1]
winner := left
if !left.Player && right.Player {
winner = right
} else if !left.Player && !right.Player {
leftStrength, rightStrength := tournamentTeamStrength(left), tournamentTeamStrength(right)
if rightStrength > leftStrength {
winner = right
} else if rightStrength == leftStrength {
pick, pickErr := tournamentRandomIndex(random, 2)
if pickErr != nil {
return nil, pickErr
}
if pick == 1 {
winner = right
}
}
}
nextRound[match] = tournamentTeamAt(winner, match)
}
rounds = append(rounds, TournamentRound{Number: round, Teams: nextRound})
previous = nextRound
}
pathActors := make([]TournamentActor, 0, tournamentRoundCount*partyCount)
for _, round := range rounds {
playerIndex := -1
for index, team := range round.Teams {
if team.Player {
playerIndex = index
break
}
}
if playerIndex < 0 {
return nil, fmt.Errorf("%w: round=%d has no player", ErrTournamentDefinitionInvalid, round.Number)
}
for _, actor := range round.Teams[playerIndex^1].Members {
if round.Number == tournamentRoundCount {
if actor.Kind == TournamentActorAICharacter {
actor.Type = 8
} else {
actor.Type = 3
}
}
pathActors = append(pathActors, actor)
}
}
return &TournamentRuntime{
Definition: definition, Rounds: rounds, PathActors: pathActors, currentRound: 1,
acceptedEvents: make(map[string]uint16), acceptedSequences: make(map[uint16]struct{}),
}, nil
}
func (r *TournamentRuntime) BindFirstActorSequence(sequence uint16) bool {
if r == nil || sequence == 0 {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
if int(sequence)+len(r.PathActors)-1 > int(^uint16(0)) {
return false
}
if r.firstSequence == 0 {
r.firstSequence = sequence
}
return r.firstSequence == sequence
}
func (r *TournamentRuntime) FirstActorSequence() uint16 {
if r == nil {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
return r.firstSequence
}
func (r *TournamentRuntime) ActorForSequence(sequence uint16) (TournamentActor, bool) {
if r == nil || sequence == 0 {
return TournamentActor{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.firstSequence == 0 || sequence < r.firstSequence {
return TournamentActor{}, false
}
index := int(sequence - r.firstSequence)
if index < 0 || index >= len(r.PathActors) {
return TournamentActor{}, false
}
return r.PathActors[index], true
}
func (r *TournamentRuntime) Snapshot() (currentRound, completedRounds byte, terminal TournamentTerminalResult) {
if r == nil {
return 0, 0, TournamentEliminated
}
r.mu.Lock()
defer r.mu.Unlock()
return r.currentRound, r.completedRounds, r.terminal
}
func (r *TournamentRuntime) ApplyActorDeath(eventID string, sequence uint16) TournamentDeathTransition {
if r == nil || strings.TrimSpace(eventID) == "" {
return TournamentDeathTransition{Disposition: TournamentDeathRejected}
}
r.mu.Lock()
defer r.mu.Unlock()
if accepted, ok := r.acceptedEvents[eventID]; ok {
disposition := TournamentDeathRejected
if accepted == sequence {
disposition = TournamentDeathDuplicate
}
return TournamentDeathTransition{Disposition: disposition, CurrentRound: r.currentRound}
}
if _, ok := r.acceptedSequences[sequence]; ok {
return TournamentDeathTransition{Disposition: TournamentDeathDuplicate, CurrentRound: r.currentRound}
}
if r.firstSequence == 0 || r.terminal != TournamentActive {
return TournamentDeathTransition{Disposition: TournamentDeathRejected, CurrentRound: r.currentRound}
}
first := int(r.firstSequence) + (int(r.currentRound)-1)*int(r.Definition.PartyCount)
end := first + int(r.Definition.PartyCount)
if int(sequence) < first || int(sequence) >= end {
return TournamentDeathTransition{Disposition: TournamentDeathRejected, CurrentRound: r.currentRound}
}
r.acceptedEvents[eventID] = sequence
r.acceptedSequences[sequence] = struct{}{}
for current := first; current < end; current++ {
if _, ok := r.acceptedSequences[uint16(current)]; !ok {
return TournamentDeathTransition{Disposition: TournamentDeathAccepted, CurrentRound: r.currentRound}
}
}
completed := r.currentRound
r.completedRounds = completed
if completed == tournamentRoundCount {
r.terminal = TournamentChampion
return TournamentDeathTransition{Disposition: TournamentCompleted, CompletedRound: completed, CurrentRound: completed}
}
r.currentRound++
return TournamentDeathTransition{Disposition: TournamentRoundCompleted, CompletedRound: completed, CurrentRound: r.currentRound}
}
func (r *TournamentRuntime) Eliminate() bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.terminal != TournamentActive {
return false
}
r.completedRounds = r.currentRound - 1
r.terminal = TournamentEliminated
return true
}
func tournamentTeamAt(team TournamentTeam, position int) TournamentTeam {
return TournamentTeam{Position: byte(position), Members: append([]TournamentActor(nil), team.Members...), Player: team.Player}
}
func tournamentTeamStrength(team TournamentTeam) uint64 {
var total uint64
for _, actor := range team.Members {
total += uint64(actor.Strength)
}
return total
}
func tournamentRandomIndex(random TournamentRandom, maximum int) (int, error) {
value, err := random(maximum)
if err != nil {
return 0, err
}
if value < 0 || value >= maximum {
return 0, fmt.Errorf("%w: value=%d maximum=%d", ErrTournamentRandomInvalid, value, maximum)
}
return value, nil
}
func shuffleTournamentActors(values []TournamentActor, random TournamentRandom) error {
for index := len(values) - 1; index > 0; index-- {
swap, err := tournamentRandomIndex(random, index+1)
if err != nil {
return err
}
values[index], values[swap] = values[swap], values[index]
}
return nil
}
func shuffleTournamentTeams(values []TournamentTeam, random TournamentRandom) error {
for index := len(values) - 1; index > 0; index-- {
swap, err := tournamentRandomIndex(random, index+1)
if err != nil {
return err
}
values[index], values[swap] = values[swap], values[index]
}
return nil
}
@@ -0,0 +1,88 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFYellowDragonTournamentDefinition(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipAreas: true})
if err != nil {
t.Fatal(err)
}
dungeon, ok := table.FindDungeonPath("dungeon/shonantournament/yellowdragon.dgn")
if !ok {
t.Fatal("yellow dragon dungeon is absent")
}
tournamentMap, ok := table.FindMapPath("map/tournament/yellowdragon_00.map")
if !ok {
t.Fatal("yellow dragon map is absent")
}
definition, err := table.TournamentDefinition(dungeon.ID, tournamentMap.ID, 1, nil)
if err != nil {
t.Fatal(err)
}
if definition.DungeonID != 120 || definition.MapID == 0 || definition.DifficultyLevel != 72 || definition.PartyCount != 1 {
t.Fatalf("identity=%+v", definition)
}
if len(definition.Candidates) != 15 || len(definition.StartAreas) != 2 || len(definition.ResultCards) != 6 || len(definition.Experience) != 5 || len(definition.RewardItems) != 3 {
t.Fatalf("definition counts: candidates=%d areas=%d cards=%d exp=%d items=%d", len(definition.Candidates), len(definition.StartAreas), len(definition.ResultCards), len(definition.Experience), len(definition.RewardItems))
}
if definition.Experience[4] != 200000 || definition.ResultCards[5].ItemWeight != 100 ||
len(dungeon.Metadata.RequiredItems) != 1 || dungeon.Metadata.RequiredItems[0] != (TicketItem{ItemID: 4726, Count: 1}) {
t.Fatalf("reward projection=%+v", definition)
}
}
func TestRealScriptPVFStoryYellowDragonUsesCardlessRewardPlan(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{SkipAreas: true})
if err != nil {
t.Fatal(err)
}
dungeon, ok := table.FindDungeonPath("dungeon/shonantournament/realizeyellowdragon.dgn")
if !ok {
t.Fatal("story yellow dragon dungeon is absent")
}
tournamentMap, ok := table.FindMap(95265)
if !ok {
t.Fatal("story yellow dragon map 95265 is absent")
}
definition, err := table.TournamentDefinition(dungeon.ID, tournamentMap.ID, 1, nil)
if err != nil {
t.Fatal(err)
}
if definition.DungeonID != 123 || definition.Experience[4] != 0 {
t.Fatalf("story reward identity dungeon=%d experience=%d", definition.DungeonID, definition.Experience[4])
}
runtime := &TournamentRuntime{
Definition: definition,
terminal: TournamentChampion,
completedRounds: tournamentRoundCount,
}
plan, _, err := runtime.PrepareRewardPlan(1, 1, func(maximum int) (int, error) { return 0, nil })
if err != nil {
t.Fatal(err)
}
if !plan.Cardless() {
t.Fatalf("story yellow dragon reward plan is not cardless: upper=%+v lower=%+v", plan.Upper, plan.Lower)
}
}
@@ -0,0 +1,245 @@
package worldmap
import (
"errors"
"testing"
)
func TestParseTournamentPVFShape(t *testing.T) {
dungeonDoc := parseTestDocument(t, "dungeon/tournament.dgn", `
[basis level]
72
[maze info]
[size]
1 1
[greed]
`+"`BB`"+`
[start map]
0 0
[boss map]
0 0
[tournament dungeon]
1
[required item]
4726 1 1
[/required item]
[tournament round fatigue]
2
[result card]
[reward item rate]
3329 60 1 3329 35 2 3329 5 3
[/reward item rate]
0 0 0 100 1 70 0 30 2 80 0 20 3 90 0 10 4 100 0 0 5 0 100 0
[/result card]
[tournament clear reward exp]
0 0 1 30000 2 50000 3 100000 4 200000
[/tournament clear reward exp]
[limit party count]
1
[tournament clear reward gold rate]
16
`)
dungeon := ParseDungeon(120, "dungeon/tournament.dgn", dungeonDoc)
if !dungeon.Metadata.Flags["tournament dungeon"] || dungeon.Metadata.TournamentRoundFatigue != 2 || dungeon.Metadata.TournamentGoldRate != 16 {
t.Fatalf("tournament scalar metadata=%+v", dungeon.Metadata)
}
if len(dungeon.Metadata.RequiredItems) != 1 || dungeon.Metadata.RequiredItems[0] != (TicketItem{ItemID: 4726, Count: 1}) {
t.Fatalf("required items=%+v", dungeon.Metadata.RequiredItems)
}
if len(dungeon.Metadata.TournamentRewardItemRates) != 3 || dungeon.Metadata.TournamentRewardItemRates[2].Count != 3 {
t.Fatalf("reward item rates=%+v", dungeon.Metadata.TournamentRewardItemRates)
}
if len(dungeon.Metadata.TournamentResultCards) != 6 || dungeon.Metadata.TournamentResultCards[5].ItemWeight != 100 {
t.Fatalf("result cards=%+v", dungeon.Metadata.TournamentResultCards)
}
if len(dungeon.Metadata.TournamentClearRewardExp) != 5 || dungeon.Metadata.TournamentClearRewardExp[4].Experience != 200000 {
t.Fatalf("clear reward experience=%+v", dungeon.Metadata.TournamentClearRewardExp)
}
mapDoc := parseTestDocument(t, "map/tournament.map", `
[dungeon]
120
[/dungeon]
[tournament start area]
1 360 270 1
[tournament start area]
1 760 270 2
[tournament enemies]
1 `+"`[monster]`"+` 56048 900 `+"`A`"+` 56047 300 `+"`B`"+`
[/tournament enemies]
`)
parsedMap := ParseMap(24001, "map/tournament.map", mapDoc)
if parsedMap.TournamentMalformed || len(parsedMap.TournamentStartAreas) != 2 || len(parsedMap.TournamentEnemies) != 2 {
t.Fatalf("tournament map=%+v", parsedMap)
}
if parsedMap.TournamentEnemies[0].Kind != "[monster]" || parsedMap.TournamentEnemies[1].Strength != 300 {
t.Fatalf("tournament enemies=%+v", parsedMap.TournamentEnemies)
}
}
func TestTournamentRuntimeBuildsBracketAndRejectsStaleDeaths(t *testing.T) {
definition := tournamentRuntimeTestDefinition()
runtime, err := NewTournamentRuntime(definition, func(maximum int) (int, error) { return 0, nil })
if err != nil {
t.Fatal(err)
}
if len(runtime.Rounds) != 4 || len(runtime.Rounds[0].Teams) != 16 || len(runtime.Rounds[3].Teams) != 2 || len(runtime.PathActors) != 4 {
t.Fatalf("rounds=%+v path=%+v", runtime.Rounds, runtime.PathActors)
}
if !runtime.BindFirstActorSequence(100) || !runtime.BindFirstActorSequence(100) || runtime.BindFirstActorSequence(101) {
t.Fatal("first actor sequence binding is not idempotent")
}
if got := runtime.ApplyActorDeath("future", 101); got.Disposition != TournamentDeathRejected {
t.Fatalf("future death=%+v", got)
}
for round := byte(1); round <= 4; round++ {
sequence := uint16(99 + round)
transition := runtime.ApplyActorDeath(string([]byte{'e', '0' + round}), sequence)
want := TournamentRoundCompleted
if round == 4 {
want = TournamentCompleted
}
if transition.Disposition != want || transition.CompletedRound != round {
t.Fatalf("round=%d transition=%+v want=%d", round, transition, want)
}
if duplicate := runtime.ApplyActorDeath(string([]byte{'e', '0' + round}), sequence); duplicate.Disposition != TournamentDeathDuplicate {
t.Fatalf("round=%d duplicate=%+v", round, duplicate)
}
}
current, completed, terminal := runtime.Snapshot()
if current != 4 || completed != 4 || terminal != TournamentChampion {
t.Fatalf("terminal snapshot=%d/%d/%d", current, completed, terminal)
}
}
func TestTournamentRuntimeRejectsInvalidRandomIndex(t *testing.T) {
_, err := NewTournamentRuntime(tournamentRuntimeTestDefinition(), func(maximum int) (int, error) { return maximum, nil })
if !errors.Is(err, ErrTournamentRandomInvalid) {
t.Fatalf("error=%v", err)
}
}
func TestTournamentRuntimeEliminationIsTerminalAndIdempotent(t *testing.T) {
runtime, err := NewTournamentRuntime(tournamentRuntimeTestDefinition(), func(maximum int) (int, error) { return 0, nil })
if err != nil {
t.Fatal(err)
}
if !runtime.BindFirstActorSequence(100) {
t.Fatal("bind sequence")
}
if !runtime.Eliminate() || runtime.Eliminate() {
t.Fatal("elimination was not a one-way transition")
}
current, completed, terminal := runtime.Snapshot()
if current != 1 || completed != 0 || terminal != TournamentEliminated {
t.Fatalf("eliminated snapshot=%d/%d/%d", current, completed, terminal)
}
if transition := runtime.ApplyActorDeath("after-elimination", 100); transition.Disposition != TournamentDeathRejected {
t.Fatalf("post-elimination death=%+v", transition)
}
}
func tournamentRuntimeTestDefinition() TournamentDefinition {
candidates := make([]TournamentActor, 15)
for index := range candidates {
candidates[index] = TournamentActor{Code: uint32(56000 + index), Strength: uint16(100 + index), Name: "actor", Level: 72}
}
return TournamentDefinition{DungeonID: 120, MapID: 24001, DifficultyLevel: 72, PartyCount: 1, Candidates: candidates}
}
func TestTournamentRewardPlanUsesUpperCompletedRoundsAndChampionLowerKey(t *testing.T) {
definition := TournamentDefinition{
PartyCount: 1,
ResultCards: map[byte]TournamentResultCard{
0: {ResultKey: 0, EmptyWeight: 1}, 1: {ResultKey: 1, EmptyWeight: 1},
2: {ResultKey: 2, EmptyWeight: 1}, 3: {ResultKey: 3, EmptyWeight: 1},
4: {ResultKey: 4, GoldWeight: 1}, 5: {ResultKey: 5, ItemWeight: 1},
},
RewardItems: []TournamentRewardItemRate{{ItemID: 3329, Weight: 1, Count: 3}},
}
runtime := &TournamentRuntime{Definition: definition, terminal: TournamentChampion, completedRounds: tournamentRoundCount}
plan, created, err := runtime.PrepareRewardPlan(200000, 777, func(maximum int) (int, error) { return 0, nil })
if err != nil || !created {
t.Fatalf("prepare plan created=%t err=%v", created, err)
}
for index, reward := range plan.Upper {
if !reward.Gold || reward.Count != 777 || reward.Empty {
t.Fatalf("upper[%d]=%+v, want frozen gold", index, reward)
}
}
for index, reward := range plan.Lower {
if reward.ItemID != 3329 || reward.Count != 3 || reward.Gold || reward.Empty {
t.Fatalf("lower[%d]=%+v, want item", index, reward)
}
}
second, created, err := runtime.PrepareRewardPlan(1, 2, func(maximum int) (int, error) { return 0, nil })
if err != nil || created || second.Experience != 200000 || second.Upper[0].Count != 777 {
t.Fatalf("second prepare created=%t plan=%+v err=%v", created, second, err)
}
}
func TestTournamentRewardPlanSelectionIsTwoPhaseAndIdempotent(t *testing.T) {
plan := TournamentRewardPlan{Champion: false, Upper: [2]TournamentReward{{ItemID: 3329, Count: 3}}}
plan.initSelection()
reward, reserved, replay := plan.ReserveSelection(0, 0)
if !reserved || replay || reward.ItemID != 3329 {
t.Fatalf("reserve=%+v reserved=%t replay=%t", reward, reserved, replay)
}
if _, second, _ := plan.ReserveSelection(0, 1); second {
t.Fatal("second card reservation unexpectedly accepted")
}
if !plan.CommitSelection(0, 0) || !plan.IsSelectionComplete() {
t.Fatal("committed non-champion selection is not complete")
}
replayReward, replayReserved, replay := plan.ReserveSelection(0, 0)
if replayReserved || !replay || replayReward.ItemID != 3329 {
t.Fatalf("replay=%+v reserved=%t replay=%t", replayReward, replayReserved, replay)
}
if _, differentReserved, differentReplay := plan.ReserveSelection(0, 1); differentReserved || differentReplay {
t.Fatalf("different card was accepted after commit: reserved=%t replay=%t", differentReserved, differentReplay)
}
}
func TestTournamentRewardPlanSelectedCardOnlyReportsCommittedSelection(t *testing.T) {
plan := TournamentRewardPlan{Champion: true}
plan.initSelection()
if _, ok := plan.SelectedCard(0); ok {
t.Fatal("unselected card was projected")
}
if _, reserved, _ := plan.ReserveSelection(0, 1); !reserved {
t.Fatal("selection was not reserved")
}
if _, ok := plan.SelectedCard(0); ok {
t.Fatal("uncommitted card was projected")
}
if !plan.CommitSelection(0, 1) {
t.Fatal("selection was not committed")
}
if index, ok := plan.SelectedCard(0); !ok || index != 1 {
t.Fatalf("selected card index=%d ok=%t, want 1/true", index, ok)
}
}
func TestTournamentCardlessRewardPlanCommitsWithoutSelectionRequest(t *testing.T) {
runtime := &TournamentRuntime{
Definition: TournamentDefinition{ResultCards: map[byte]TournamentResultCard{
4: {ResultKey: 4, EmptyWeight: 1},
5: {ResultKey: 5, EmptyWeight: 1},
}},
terminal: TournamentChampion,
completedRounds: tournamentRoundCount,
}
plan, _, err := runtime.PrepareRewardPlan(123, 456, func(maximum int) (int, error) { return 0, nil })
if err != nil {
t.Fatal(err)
}
if !plan.Cardless() || runtime.RewardSelectionComplete() {
t.Fatalf("initial cardless plan=%+v complete=%t", plan, runtime.RewardSelectionComplete())
}
if !runtime.CommitCardlessRewardPlan() || !runtime.RewardSelectionComplete() {
t.Fatal("cardless reward plan did not cross the delivery gate")
}
if !runtime.CommitCardlessRewardPlan() {
t.Fatal("cardless reward completion replay was not idempotent")
}
}
@@ -0,0 +1,46 @@
package worldmap
import (
"context"
"os"
"strings"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestProductionTrainingDungeon5000ResolvesOwnedStartMap(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatal(err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
topology, err := BuildDungeonLayout(resolver, 5000, 0, nil)
if err != nil {
t.Fatalf("build production training-room layout: %v", err)
}
room, ok := topology.Room(RoomCoordinate{X: 0, Y: 0})
if !ok || !room.Start || !room.Boss || room.Map == nil {
t.Fatalf("production training-room start/boss room = %+v ok=%v", room, ok)
}
if room.Map.Map.ID != 36250 ||
!strings.EqualFold(room.Map.Map.Path, "map/PoongjinTrainingRoom/11100(0,0)start.map") ||
room.Map.Source != ResolutionDungeonOwnership {
t.Fatalf("production training-room map = %+v", room.Map)
}
if len(topology.UnresolvedRooms()) != 0 || len(topology.resolutionErrors) != 0 {
t.Fatalf("layout unresolved = %+v errors=%v", topology.UnresolvedRooms(), topology.resolutionErrors)
}
}
@@ -0,0 +1,359 @@
package worldmap
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
const (
DefaultMapList = "map/map.lst"
DefaultWorldMapList = "worldmap/worldmap.lst"
DefaultDungeonList = "dungeon/dungeon.lst"
)
type OptionalInt struct {
Value int64 `json:"value"`
Set bool `json:"set"`
}
type RawSection struct {
Name string `json:"name"`
Occurrence int `json:"occurrence"`
Scope []string `json:"scope,omitempty"`
Block bool `json:"block,omitempty"`
Tokens []dnfpvf.Token `json:"tokens,omitempty"`
}
type Diagnostic struct {
Section string `json:"section"`
Occurrence int `json:"occurrence"`
Message string `json:"message"`
}
type Point struct {
X int64 `json:"x"`
Y int64 `json:"y"`
}
type Point3 struct {
X int64 `json:"x"`
Y int64 `json:"y"`
Z int64 `json:"z"`
}
type Portal struct {
Slot int `json:"slot"`
Position *Point `json:"position,omitempty"`
ObjectID OptionalInt `json:"object_id"`
}
type MovableArea struct {
X int64 `json:"x"`
Y int64 `json:"y"`
Width int64 `json:"width"`
Height int64 `json:"height"`
Params [2]int64 `json:"params"`
}
type TileFile struct {
Index int64 `json:"index"`
Path string `json:"path"`
}
type MapSummon struct {
Key OptionalInt `json:"key"`
Position []int64 `json:"position,omitempty"`
Type string `json:"type,omitempty"`
ItemCount OptionalInt `json:"item_count"`
Index OptionalInt `json:"index"`
LifeTime OptionalInt `json:"life_time"`
Info []ExtensionSection `json:"info,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
}
type BackgroundAnimation struct {
Filename string `json:"filename,omitempty"`
Layer string `json:"layer,omitempty"`
Order string `json:"order,omitempty"`
}
type AnimationObject struct {
Filename string `json:"filename"`
Layer string `json:"layer"`
Position Point3 `json:"position"`
}
type PassiveObject struct {
ObjectID int64 `json:"object_id"`
X int64 `json:"x"`
Y int64 `json:"y"`
Flags int64 `json:"flags"`
}
type SpecialObjectSpawn struct {
Kind string `json:"kind"`
Code int64 `json:"code"`
Level int64 `json:"level"`
Params [3]int64 `json:"params"`
}
type HellPartyEntry struct {
GroupID int64 `json:"group_id"`
Rate int64 `json:"rate"`
Order int64 `json:"order"`
}
type SpecialPassiveObject struct {
PassiveObject
Spawns []SpecialObjectSpawn `json:"spawns,omitempty"`
HellParty []HellPartyEntry `json:"hell_party,omitempty"`
}
type MonsterSpawn struct {
MonsterID int64 `json:"monster_id"`
Level int64 `json:"level"`
AutoLevel int64 `json:"auto_level"`
Position Point3 `json:"position"`
RandomDropCount int64 `json:"random_drop_count"`
FixedDropCount int64 `json:"fixed_drop_count"`
Placement string `json:"placement"`
Rank string `json:"rank"`
SuffixMarker string `json:"suffix_marker,omitempty"`
}
type NPCSpawn struct {
NPCID int64 `json:"npc_id"`
Direction string `json:"direction,omitempty"`
Position Point3 `json:"position"`
Params []int64 `json:"params,omitempty"`
}
type AICharacter struct {
Code int64 `json:"code"`
Position Point `json:"position"`
Direction int64 `json:"direction"`
Faction string `json:"faction"`
AIType string `json:"ai_type"`
Params []int64 `json:"params,omitempty"`
}
// TournamentEnemyCandidate is one PVF [tournament enemies] row projected into
// a typed definition for the Yellow Dragon tournament runtime.
type TournamentEnemyCandidate struct {
PartyCount int64 `json:"party_count"`
Kind string `json:"kind"`
Code int64 `json:"code"`
Strength int64 `json:"strength"`
Name string `json:"name"`
}
type TournamentStartArea struct {
PartyCount int64 `json:"party_count"`
X int64 `json:"x"`
Y int64 `json:"y"`
Direction int64 `json:"direction"`
}
// BloodAltarDifficulty is the two-way branch encoded by the final column of
// an Ultimate Altar [ultimate phase time] row.
type BloodAltarDifficulty string
const (
BloodAltarDifficultyA BloodAltarDifficulty = "A"
BloodAltarDifficultyB BloodAltarDifficulty = "B"
)
// BloodAltarMonster is one ten-column spawn template from [blood monster] or
// [ultimate monster]. Phase rows address this table by zero-based index.
type BloodAltarMonster struct {
MonsterCode int64 `json:"monster_code"`
TemplateType int64 `json:"template_type"`
Position Point3 `json:"position"`
DurationMilliseconds int64 `json:"duration_milliseconds"`
SpawnIntervalMilliseconds int64 `json:"spawn_interval_milliseconds"`
BaseSpawnCount int64 `json:"base_spawn_count"`
SpawnCountIncrement int64 `json:"spawn_count_increment"`
BatchCount int64 `json:"batch_count"`
}
// BloodAltarPhase is one six-column Endless Altar phase or seven-column
// Ultimate Altar phase. Difficulty is empty for Endless Altar rows.
type BloodAltarPhase struct {
Round int64 `json:"round"`
MonsterTemplateIndex int64 `json:"monster_template_index"`
DelayMilliseconds int64 `json:"delay_milliseconds"`
Scale float64 `json:"scale"`
Flag int64 `json:"flag"`
ConcurrentPhaseCount int64 `json:"concurrent_phase_count"`
Difficulty BloodAltarDifficulty `json:"difficulty,omitempty"`
}
type TournamentResultCard struct {
ResultKey int64 `json:"result_key"`
GoldWeight int64 `json:"gold_weight"`
ItemWeight int64 `json:"item_weight"`
EmptyWeight int64 `json:"empty_weight"`
}
type TournamentRoundExperience struct {
CompletedRounds int64 `json:"completed_rounds"`
Experience int64 `json:"experience"`
}
type TournamentRewardItemRate struct {
ItemID int64 `json:"item_id"`
Weight int64 `json:"weight"`
Count int64 `json:"count"`
}
type MapFlags struct {
BlockUseStackableItem bool `json:"block_use_stackable_item,omitempty"`
BlockUseActiveSkill bool `json:"block_use_active_skill,omitempty"`
VisibleOnDungeonClear bool `json:"visible_on_dungeon_clear,omitempty"`
LoopYAxis bool `json:"loop_y_axis,omitempty"`
AllDeadCasePassable bool `json:"all_dead_case_passable,omitempty"`
DisableItemEscapeStuck bool `json:"disable_item_escape_stuck,omitempty"`
DisableCharacterEscapeStuck bool `json:"disable_character_escape_stuck,omitempty"`
CannotUseCoinMap bool `json:"cannot_use_coin_map,omitempty"`
NoRevivalTimerLimit bool `json:"no_revival_timer_limit,omitempty"`
IgnoreDiehard bool `json:"ignore_diehard,omitempty"`
DisableRebirth bool `json:"disable_rebirth,omitempty"`
PreservePlayerCorpse bool `json:"preserve_player_corpse,omitempty"`
CannotUseResolutionChangeZoom bool `json:"cannot_use_resolution_change_zoom,omitempty"`
CenterFixedCamera bool `json:"center_fixed_camera,omitempty"`
ForceDrawPattern bool `json:"force_draw_pattern,omitempty"`
IsRevival bool `json:"is_revival,omitempty"`
IsMovieEnd bool `json:"is_movie_end,omitempty"`
QuestStartMap bool `json:"quest_start_map,omitempty"`
HideMonster bool `json:"hide_monster,omitempty"`
ShowDust bool `json:"show_dust,omitempty"`
}
type Map struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name,omitempty"`
PlayerNumber []int64 `json:"player_number,omitempty"`
PVPStartArea []int64 `json:"pvp_start_area,omitempty"`
DungeonID OptionalInt `json:"dungeon_id"`
DungeonIDs []int64 `json:"dungeon_ids,omitempty"`
Type string `json:"type,omitempty"`
Greed string `json:"greed,omitempty"`
Tiles []string `json:"tiles,omitempty"`
TileFiles []TileFile `json:"tile_files,omitempty"`
TileMapRows [][]int64 `json:"tile_map_rows,omitempty"`
FarSightScroll OptionalInt `json:"far_sight_scroll"`
MiddleSightScroll OptionalInt `json:"middle_sight_scroll"`
NearSightScroll OptionalInt `json:"near_sight_scroll"`
BackgroundAnimations []BackgroundAnimation `json:"background_animations,omitempty"`
PathgatePositions []Point `json:"pathgate_positions,omitempty"`
PathgateObjects []int64 `json:"pathgate_objects,omitempty"`
Portals []Portal `json:"portals,omitempty"`
Sounds []string `json:"sounds,omitempty"`
AnimationObjects []AnimationObject `json:"animation_objects,omitempty"`
PassiveObjects []PassiveObject `json:"passive_objects,omitempty"`
SpecialPassiveObjects []SpecialPassiveObject `json:"special_passive_objects,omitempty"`
Monsters []MonsterSpawn `json:"monsters,omitempty"`
EventMonsterPositions []Point3 `json:"event_monster_positions,omitempty"`
NPCs []NPCSpawn `json:"npcs,omitempty"`
AICharacters []AICharacter `json:"ai_characters,omitempty"`
TournamentEnemies []TournamentEnemyCandidate `json:"tournament_enemies,omitempty"`
TournamentStartAreas []TournamentStartArea `json:"tournament_start_areas,omitempty"`
TournamentMalformed bool `json:"tournament_malformed,omitempty"`
BloodMonsters []BloodAltarMonster `json:"blood_monsters,omitempty"`
BloodPhases []BloodAltarPhase `json:"blood_phases,omitempty"`
UltimateMonsters []BloodAltarMonster `json:"ultimate_monsters,omitempty"`
UltimatePhases []BloodAltarPhase `json:"ultimate_phases,omitempty"`
TownMovableAreas []MovableArea `json:"town_movable_areas,omitempty"`
DungeonMovableAreas []MovableArea `json:"dungeon_movable_areas,omitempty"`
Summons []MapSummon `json:"summons,omitempty"`
FixChampion OptionalInt `json:"fix_champion"`
HeroesModeMapIndex OptionalInt `json:"heroes_mode_map_index"`
BackgroundCorrection OptionalInt `json:"background_correction"`
BackgroundPos OptionalInt `json:"background_pos"`
ForegroundPatternAlpha OptionalInt `json:"foreground_pattern_alpha"`
APCRandomPoint OptionalInt `json:"apc_random_point"`
MonsterLock OptionalInt `json:"monster_lock"`
DrawMonsterCount OptionalInt `json:"draw_monster_count"`
SortBottom OptionalInt `json:"sort_bottom"`
AddGravity OptionalInt `json:"add_gravity"`
JumpPowerRate OptionalInt `json:"jump_power_rate"`
Flags MapFlags `json:"flags"`
DungeonStartArea []int64 `json:"dungeon_start_area,omitempty"`
ScreenPosition []int64 `json:"screen_position,omitempty"`
MonsterTeam []int64 `json:"monster_team,omitempty"`
PVPPracticeStartArea []int64 `json:"pvp_practice_start_area,omitempty"`
VirtualMovableArea []int64 `json:"virtual_movable_area,omitempty"`
OpeningBGM []string `json:"opening_bgm,omitempty"`
MapLoadingImagePath string `json:"map_loading_image_path,omitempty"`
BasicAction string `json:"basic_action,omitempty"`
AbsoluteStartPath []string `json:"absolute_start_path,omitempty"`
RandomStartMap OptionalInt `json:"random_start_map"`
PathgateRecognizeRange OptionalInt `json:"pathgate_recognize_range"`
SourceSections []RawSection `json:"source_sections,omitempty"`
OpaqueSections []RawSection `json:"opaque_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
type DungeonEntry struct {
DungeonID int64 `json:"dungeon_id"`
QuestID int64 `json:"quest_id"`
InProgressOnly bool `json:"in_progress_only,omitempty"`
}
type TicketItem struct {
ItemID int64 `json:"item_id"`
Count int64 `json:"count"`
}
type Resource struct {
Path string `json:"path,omitempty"`
Params []int64 `json:"params,omitempty"`
}
type Area struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name,omitempty"`
MapImage Resource `json:"map_image"`
UIPath string `json:"ui_path,omitempty"`
Dungeons []DungeonEntry `json:"dungeons,omitempty"`
HellDungeon OptionalInt `json:"hell_dungeon"`
HellQuestIDs []int64 `json:"hell_quest_ids,omitempty"`
HellFreePassItems []TicketItem `json:"hell_free_pass_items,omitempty"`
InfernoFreePassItems []TicketItem `json:"inferno_free_pass_items,omitempty"`
ParticularHellFreePassItems []TicketItem `json:"particular_hell_free_pass_items,omitempty"`
ItemConditions []int64 `json:"item_conditions,omitempty"`
SourceSections []RawSection `json:"source_sections,omitempty"`
OpaqueSections []RawSection `json:"opaque_sections,omitempty"`
UnknownSections []RawSection `json:"unknown_sections,omitempty"`
Extensions []ExtensionSection `json:"extensions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
type Options struct {
MapListPath string
WorldMapListPath string
DungeonListPath string
SkipMaps bool
SkipAreas bool
SkipDungeons bool
// CompactRuntime drops parser evidence that is not consulted by runtime
// routing after all typed map, maze and mechanism fields are resolved.
// Audit and export callers leave this false to retain raw sections.
CompactRuntime bool
}
type Snapshot struct {
Maps int `json:"maps"`
Areas int `json:"areas"`
Dungeons int `json:"dungeons"`
Mazes int `json:"mazes"`
DungeonRefs int `json:"dungeon_refs"`
}
@@ -0,0 +1,76 @@
package worldmap
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestZZDiagTimeRiftMaze3 dumps the real-PVF topology for dungeon 7539
// (Inferno_MetroCenter1 / 时空裂缝) maze 3 so the live op45 hang can be
// explained from the actual room graph instead of guessed.
func TestZZDiagTimeRiftMaze3(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("DNF_WORLDMAP_REAL_PVF_SMOKE is not set")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
table, err := LoadSource(context.Background(), archive, Options{})
if err != nil {
t.Fatal(err)
}
resolver, err := NewResolver(table)
if err != nil {
t.Fatal(err)
}
dungeon, found := table.FindDungeon(7539)
if !found {
t.Fatal("dungeon 7539 missing")
}
t.Logf("dungeon=%d path=%s mazes=%d", dungeon.ID, dungeon.Path, len(dungeon.Mazes))
for mazeIndex, maze := range dungeon.Mazes {
if mazeIndex != 3 {
continue
}
t.Logf("MAZE %d index=%d start=%v boss=%v w=%v h=%v greed=%q questConnection=%v",
mazeIndex, maze.Index, maze.Start, maze.Boss, maze.Width, maze.Height, maze.Greed, maze.QuestConnection)
for _, spec := range maze.MapSpecifications {
t.Logf(" MAPSPEC type=%q coord=%d:%d mapIDs=%v", spec.Type, spec.Coordinate.X, spec.Coordinate.Y, spec.MapIDs)
}
for _, spec := range maze.LayeredSpecifications {
t.Logf(" LAYEREDSPEC coord=%d:%d mapIDs=%v", spec.Coordinate.X, spec.Coordinate.Y, spec.MapIDs)
}
t.Logf(" layeredRawSections=%d", len(maze.LayeredMapSpecifications))
for _, cond := range maze.WarpMapConditions {
t.Logf(" WARPCOND %+v", cond)
}
t.Logf(" clearConditions=%+v", maze.ClearConditions)
}
topology, err := BuildDungeonTopology(resolver, dungeon.ID, 3)
if err != nil {
t.Fatalf("build topology: %v", err)
}
t.Logf("TOPOLOGY start=%v bosses=%v width=%v height=%v allowMoveBeforeClear=%t",
topology.Start, topology.Bosses, topology.Width, topology.Height, topology.AllowMoveBeforeClear)
rooms := topology.Rooms()
for _, room := range rooms {
mapID := int64(0)
monsters := 0
if room.Map != nil {
mapID = room.Map.Map.ID
monsters = len(room.Map.Map.Monsters)
}
t.Logf("ROOM %s start=%t boss=%t map=%d monsters=%d neighbors=%v err=%q",
room.Coordinate, room.Start, room.Boss, mapID, monsters, room.Neighbors, room.ResolutionError)
}
for _, d := range topology.Diagnostics {
t.Logf("DIAG code=%s coord=%v msg=%s", d.Code, d.Coordinate, d.Message)
}
}