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