Files
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

926 lines
31 KiB
Go

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
}