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

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

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

No files matched your search

@@ -0,0 +1,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
}