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

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

873 lines
27 KiB
Go

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)
}
}