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