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