服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1050 lines
37 KiB
Go
1050 lines
37 KiB
Go
package worldmap
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
func ParseDungeon(id int64, dungeonPath string, doc *dnfpvf.Document) Dungeon {
|
|
result := Dungeon{ID: id, Path: dungeonPath}
|
|
sections, diagnostics := rawSections(doc)
|
|
result.SourceSections = sections
|
|
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
|
|
|
currentMaze := -1
|
|
complexBlock := ""
|
|
for _, section := range sections {
|
|
key := sectionKey(section.Name)
|
|
if key == "maze info" {
|
|
result.Mazes = append(result.Mazes, Maze{Index: len(result.Mazes)})
|
|
currentMaze = len(result.Mazes) - 1
|
|
result.Mazes[currentMaze].SourceSections = append(result.Mazes[currentMaze].SourceSections, cloneRawSection(section))
|
|
continue
|
|
}
|
|
if currentMaze >= 0 && key == "escape hell" {
|
|
// [escape hell] is physically emitted after the [maze info] it
|
|
// qualifies. Keep it on that selected-maze route even though older
|
|
// dungeon metadata handling also recognises the same section name.
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
continue
|
|
}
|
|
if currentMaze >= 0 && complexBlock != "" {
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
if key == "/"+complexBlock {
|
|
complexBlock = ""
|
|
}
|
|
continue
|
|
}
|
|
if key == "special passive object item" && currentMaze >= 0 {
|
|
// This section is positional in the live PVF. Inferno dungeons place
|
|
// it after the maze it belongs to, while standalone particular-hell
|
|
// dungeons place one trailing definition after all mazes. Keep the
|
|
// positional owner here and normalize the latter form after parsing.
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
continue
|
|
}
|
|
if key == "boss room entrance condition" && currentMaze >= 0 {
|
|
// This gate is physically placed after the maze it owns. Its value is
|
|
// one flat token stream, despite the bracketed labels looking like
|
|
// nested PVF sections.
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
continue
|
|
}
|
|
if currentMaze >= 0 && scopeOwnedByDungeonMetadata(section.Scope) {
|
|
// Flag-style dungeon metadata is represented by an open PVF section
|
|
// without an explicit close. Later root-level metadata can therefore
|
|
// retain that flag in its syntactic scope. Keep recognised metadata
|
|
// (notably Time Rift's [inferno quest]) in the dungeon model instead
|
|
// of demoting it to an opaque extension.
|
|
if isDungeonMetadataSection(key) {
|
|
parseDungeonMetadata(&result, section)
|
|
} else {
|
|
parseDungeonExtension(&result, section)
|
|
}
|
|
continue
|
|
}
|
|
if currentMaze >= 0 && (key == "randomized object creation" || key == "clear condition") {
|
|
complexBlock = key
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
continue
|
|
}
|
|
if currentMaze >= 0 && !isDungeonMetadataSection(key) {
|
|
parseMazeSection(&result.Mazes[currentMaze], section)
|
|
continue
|
|
}
|
|
parseDungeonMetadata(&result, section)
|
|
}
|
|
parseScopedDungeonMechanismMetadata(&result, sections)
|
|
normalizeDungeonPassiveObjectItemOwnership(&result)
|
|
for index := range result.Mazes {
|
|
parseMazeComplexSections(&result.Mazes[index])
|
|
}
|
|
parseWarpMapConditions(&result, sections)
|
|
parseTournamentDungeonSections(&result, sections)
|
|
return result
|
|
}
|
|
|
|
func normalizeDungeonPassiveObjectItemOwnership(result *Dungeon) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
for index := range result.Mazes {
|
|
result.Mazes[index].SpecialPassiveObjectItems = uniqueSpecialPassiveObjectItems(
|
|
result.Mazes[index].SpecialPassiveObjectItems,
|
|
)
|
|
}
|
|
result.Metadata.SpecialPassiveObjectItems = uniqueSpecialPassiveObjectItems(
|
|
result.Metadata.SpecialPassiveObjectItems,
|
|
)
|
|
// The table is dungeon-wide, wherever the file physically places it. The
|
|
// live PVF writes it after the first [maze info] (格兰迪发电站 7136: the
|
|
// table follows maze 0 while the twelve-pillar map 53462 sits on maze 1),
|
|
// after the final [maze info] (particular hell, coloured [special dungeon]),
|
|
// repeated identically under several mazes (发电站 7130-7135, inferno
|
|
// 7527-7542) or before any maze (3080, 164). 86JP's DungeonFile keeps one
|
|
// per-dungeon vector for all of them; promote every route-local copy into
|
|
// the dungeon table and dedupe identical rows so repeated copies do not
|
|
// stack their rates.
|
|
for index := range result.Mazes {
|
|
result.Metadata.SpecialPassiveObjectItems = append(
|
|
result.Metadata.SpecialPassiveObjectItems,
|
|
result.Mazes[index].SpecialPassiveObjectItems...,
|
|
)
|
|
result.Mazes[index].SpecialPassiveObjectItems = nil
|
|
}
|
|
result.Metadata.SpecialPassiveObjectItems = uniqueSpecialPassiveObjectItems(
|
|
result.Metadata.SpecialPassiveObjectItems,
|
|
)
|
|
}
|
|
|
|
func uniqueSpecialPassiveObjectItems(rows []SpecialPassiveObjectItem) []SpecialPassiveObjectItem {
|
|
if len(rows) < 2 {
|
|
return rows
|
|
}
|
|
type key struct {
|
|
index, level, itemID, rate int64
|
|
}
|
|
seen := make(map[key]struct{}, len(rows))
|
|
out := make([]SpecialPassiveObjectItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
identity := key{row.Index, row.LevelOverride, row.ItemID, row.DropRate}
|
|
if _, exists := seen[identity]; exists {
|
|
continue
|
|
}
|
|
seen[identity] = struct{}{}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Some legacy dungeon files place dungeon-wide mechanism settings inside a
|
|
// syntactic [clear condition] block. They still configure the whole run rather
|
|
// than one destroy/hunt condition; preserve that real-PVF ownership explicitly
|
|
// instead of depending on the section's physical position after [maze info].
|
|
func parseScopedDungeonMechanismMetadata(result *Dungeon, sections []RawSection) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
for _, section := range sections {
|
|
if sectionKey(section.Name) != "limit time" || !scopeContains(section.Scope, "clear condition") {
|
|
continue
|
|
}
|
|
values, valid := ints(section)
|
|
if !valid || len(values) != 2 || values[0] <= 0 || values[1] <= 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "limit time must contain exactly two positive second durations"))
|
|
continue
|
|
}
|
|
result.Metadata.LimitTime = DungeonLimitTime{
|
|
PrimarySeconds: values[0], SecondarySeconds: values[1], Set: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseWarpMapConditions(result *Dungeon, sections []RawSection) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
currentMaze := -1
|
|
pendingMaze := -1
|
|
var pending *Point
|
|
for _, section := range sections {
|
|
if sectionKey(section.Name) == "maze info" {
|
|
if pending != nil {
|
|
result.Diagnostics = append(result.Diagnostics, Diagnostic{
|
|
Section: "warp map condition", Message: "warp source has no destination",
|
|
})
|
|
}
|
|
currentMaze++
|
|
pendingMaze = -1
|
|
pending = nil
|
|
continue
|
|
}
|
|
if !scopeContains(section.Scope, "warp map condition") {
|
|
continue
|
|
}
|
|
key := sectionKey(section.Name)
|
|
if key != "source grid pos" && key != "dest grid pos" {
|
|
continue
|
|
}
|
|
values, valid := ints(section)
|
|
if !valid || len(values) != 2 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "warp grid position must contain exactly two integers"))
|
|
if key == "source grid pos" {
|
|
pending = nil
|
|
}
|
|
continue
|
|
}
|
|
point := Point{X: values[0], Y: values[1]}
|
|
if key == "source grid pos" {
|
|
if pending != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "warp source replaced before a destination was declared"))
|
|
}
|
|
pending = &point
|
|
pendingMaze = currentMaze
|
|
continue
|
|
}
|
|
if pending == nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "warp destination has no preceding source"))
|
|
continue
|
|
}
|
|
condition := WarpMapCondition{
|
|
Source: *pending, Destination: point,
|
|
}
|
|
result.WarpMapConditions = append(result.WarpMapConditions, condition)
|
|
if pendingMaze >= 0 && pendingMaze < len(result.Mazes) {
|
|
result.Mazes[pendingMaze].WarpMapConditions = append(
|
|
result.Mazes[pendingMaze].WarpMapConditions,
|
|
condition,
|
|
)
|
|
}
|
|
pendingMaze = -1
|
|
pending = nil
|
|
}
|
|
if pending != nil {
|
|
result.Diagnostics = append(result.Diagnostics, Diagnostic{
|
|
Section: "warp map condition", Message: "warp source has no destination",
|
|
})
|
|
}
|
|
}
|
|
|
|
func scopeOwnedByDungeonMetadata(scope []string) bool {
|
|
for _, name := range scope {
|
|
if isDungeonMetadataSection(name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseDungeonExtension(result *Dungeon, section RawSection) {
|
|
if isClosingSection(section.Name) {
|
|
return
|
|
}
|
|
result.UnknownSections = append(result.UnknownSections, cloneRawSection(section))
|
|
result.Extensions = append(result.Extensions, structureExtension(section))
|
|
}
|
|
|
|
func parseDungeonMetadata(result *Dungeon, section RawSection) {
|
|
key := sectionKey(section.Name)
|
|
if isClosingSection(key) {
|
|
return
|
|
}
|
|
if key == "blood dungeon" {
|
|
parseBloodAltarDungeonKind(result, section)
|
|
return
|
|
}
|
|
if key == "blood max round" {
|
|
parseBloodAltarMaxRounds(result, section)
|
|
return
|
|
}
|
|
// The current PVF writes Time Rift's opening quest as an explicitly closed
|
|
// list. It is still scalar dungeon metadata, not an opaque composite
|
|
// structure; parse it before the generic block preservation below.
|
|
if key == "inferno quest" {
|
|
parseInfernoQuestIDs(result, section)
|
|
return
|
|
}
|
|
if key == "special passive object item" {
|
|
result.Metadata.SpecialPassiveObjectItems = append(
|
|
result.Metadata.SpecialPassiveObjectItems,
|
|
parseSpecialPassiveObjectItems(section, &result.Diagnostics)...,
|
|
)
|
|
return
|
|
}
|
|
if key == "event monster" || key == "event monster2" || key == "event monster3" {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed) == 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "event-monster definition must contain at least one integer"))
|
|
return
|
|
}
|
|
result.Metadata.EventMonsters = append(result.Metadata.EventMonsters, DungeonEventMonsterDefinition{
|
|
Kind: key, Values: append([]int64(nil), parsed...),
|
|
})
|
|
return
|
|
}
|
|
if key == "event monster random map" {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed) != 1 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "event-monster random-map selector must contain exactly one integer"))
|
|
return
|
|
}
|
|
result.Metadata.EventMonsterRandomMap = OptionalInt{Value: parsed[0], Set: true}
|
|
return
|
|
}
|
|
if section.Block {
|
|
result.OpaqueSections = append(result.OpaqueSections, cloneRawSection(section))
|
|
result.Extensions = append(result.Extensions, structureExtension(section))
|
|
return
|
|
}
|
|
if _, ok := opaqueDungeonSections[key]; ok {
|
|
result.OpaqueSections = append(result.OpaqueSections, cloneRawSection(section))
|
|
return
|
|
}
|
|
if _, ok := dungeonIntegerSections[key]; ok {
|
|
value, ok := firstInt(section)
|
|
if !ok {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected integer metadata value"))
|
|
return
|
|
}
|
|
if result.Metadata.IntegerValues == nil {
|
|
result.Metadata.IntegerValues = make(map[string]int64)
|
|
}
|
|
result.Metadata.IntegerValues[key] = value
|
|
switch key {
|
|
case "coin limit":
|
|
// [coin limit]:一次进图最多能用几个复活币。卢克三个圣所各写 8,
|
|
// 二阶段的 PHASE COIN LIMIT 20 是另一层(全队总次数)。
|
|
// 仅新增这一条,上游同版本里的其它分支(遗忘之地 fever time 等)本树没有,不引入。
|
|
result.Metadata.CoinLimit = OptionalInt{Value: value, Set: true}
|
|
case "hell party required item count":
|
|
result.Metadata.HellPartyRequiredItemCount = OptionalInt{Value: value, Set: true}
|
|
case "tournament round fatigue":
|
|
result.Metadata.TournamentRoundFatigue = value
|
|
case "tournament clear reward gold rate":
|
|
result.Metadata.TournamentGoldRate = value
|
|
}
|
|
return
|
|
}
|
|
if _, ok := dungeonNumberSections[key]; ok {
|
|
value, ok := firstNumber(section)
|
|
if !ok {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected numeric metadata value"))
|
|
return
|
|
}
|
|
if result.Metadata.NumberValues == nil {
|
|
result.Metadata.NumberValues = make(map[string]float64)
|
|
}
|
|
result.Metadata.NumberValues[key] = value
|
|
return
|
|
}
|
|
if _, ok := dungeonFlagSections[key]; ok {
|
|
if result.Metadata.Flags == nil {
|
|
result.Metadata.Flags = make(map[string]bool)
|
|
}
|
|
result.Metadata.Flags[key] = true
|
|
switch key {
|
|
case "inferno dungeon":
|
|
result.Metadata.InfernoDungeon = true
|
|
case "select dungeon normal disable":
|
|
result.Metadata.SelectDungeonNormalDisabled = true
|
|
}
|
|
return
|
|
}
|
|
if _, ok := dungeonTextSections[key]; ok {
|
|
parsed := texts(section)
|
|
if len(parsed) == 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected text metadata value"))
|
|
return
|
|
}
|
|
if result.Metadata.TextValues == nil {
|
|
result.Metadata.TextValues = make(map[string][]string)
|
|
}
|
|
result.Metadata.TextValues[key] = append(result.Metadata.TextValues[key], parsed...)
|
|
return
|
|
}
|
|
if _, ok := dungeonCoreSections[key]; !ok {
|
|
result.UnknownSections = append(result.UnknownSections, cloneRawSection(section))
|
|
result.Extensions = append(result.Extensions, structureExtension(section))
|
|
return
|
|
}
|
|
|
|
switch key {
|
|
case "name":
|
|
setText(&result.Metadata.Name, section, &result.Diagnostics)
|
|
case "explain":
|
|
setText(&result.Metadata.Explain, section, &result.Diagnostics)
|
|
case "cutscene image":
|
|
if value, ok := firstText(section); ok {
|
|
result.Metadata.CutsceneImage.Path = value
|
|
} else {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected cutscene image path"))
|
|
}
|
|
parsed, _ := ints(section)
|
|
result.Metadata.CutsceneImage.Params = append(result.Metadata.CutsceneImage.Params, parsed...)
|
|
case "minimap image":
|
|
setText(&result.Metadata.MinimapImage, section, &result.Diagnostics)
|
|
case "entering title":
|
|
setText(&result.Metadata.EnteringTitle, section, &result.Diagnostics)
|
|
case "minimum required level":
|
|
setOptionalInt(&result.Metadata.MinimumRequiredLevel, section, &result.Diagnostics)
|
|
case "basis level":
|
|
setOptionalInt(&result.Metadata.BasisLevel, section, &result.Diagnostics)
|
|
case "experience increasing point":
|
|
setOptionalNumber(&result.Metadata.ExperienceIncreasingPoint, section, &result.Diagnostics)
|
|
case "background pos":
|
|
setOptionalInt(&result.Metadata.BackgroundPosition, section, &result.Diagnostics)
|
|
case "dungeon type":
|
|
setText(&result.Metadata.DungeonType, section, &result.Diagnostics)
|
|
case "champion":
|
|
result.Metadata.Champion = appendInts(result.Metadata.Champion, section, &result.Diagnostics)
|
|
case "pathgate object":
|
|
result.Metadata.PathgateObjects = appendInts(result.Metadata.PathgateObjects, section, &result.Diagnostics)
|
|
case "worldmap pattern info":
|
|
result.Metadata.WorldMapPatternInfo = append(result.Metadata.WorldMapPatternInfo, cloneRawSection(section))
|
|
case "worldmap info":
|
|
result.Metadata.WorldMapInfo = append(result.Metadata.WorldMapInfo, cloneRawSection(section))
|
|
case "difficulty":
|
|
setOptionalInt(&result.Metadata.Difficulty, section, &result.Diagnostics)
|
|
case "difficulty level":
|
|
result.Metadata.DifficultyLevels = appendInts(result.Metadata.DifficultyLevels, section, &result.Diagnostics)
|
|
case "designate dungeon difficulty":
|
|
result.Metadata.DesignatedDifficulties = appendInts(result.Metadata.DesignatedDifficulties, section, &result.Diagnostics)
|
|
case "tutorial dungeon":
|
|
setOptionalInt(&result.Metadata.TutorialDungeon, section, &result.Diagnostics)
|
|
case "no fatigue":
|
|
result.Metadata.NoFatigue = true
|
|
case "named monster":
|
|
result.Metadata.NamedMonsters = appendInts(result.Metadata.NamedMonsters, section, &result.Diagnostics)
|
|
case "recommended level":
|
|
result.Metadata.RecommendedLevels = appendInts(result.Metadata.RecommendedLevels, section, &result.Diagnostics)
|
|
case "limit party count":
|
|
setOptionalInt(&result.Metadata.LimitPartyCount, section, &result.Diagnostics)
|
|
case "lost land parameters":
|
|
values, valid := ints(section)
|
|
if !valid || len(values) != 2 || values[0] <= 0 || values[1] <= 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "lost land parameters must contain exactly two positive millisecond durations"))
|
|
return
|
|
}
|
|
result.Metadata.LostLandParameters = LostLandParameters{
|
|
DayMilliseconds: values[0], NightMilliseconds: values[1], Set: true,
|
|
}
|
|
case "limit time":
|
|
values, valid := ints(section)
|
|
if !valid || len(values) != 2 || values[0] <= 0 || values[1] <= 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "limit time must contain exactly two positive second durations"))
|
|
return
|
|
}
|
|
result.Metadata.LimitTime = DungeonLimitTime{
|
|
PrimarySeconds: values[0], SecondarySeconds: values[1], Set: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseBloodAltarDungeonKind(result *Dungeon, section RawSection) {
|
|
if result.Metadata.Flags == nil {
|
|
result.Metadata.Flags = make(map[string]bool)
|
|
}
|
|
result.Metadata.Flags["blood dungeon"] = true
|
|
|
|
tokens := values(section)
|
|
if len(tokens) != 1 || !bloodAltarInt32Token(tokens[0]) {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "blood dungeon kind must be exactly one integer (1 or 2)"))
|
|
return
|
|
}
|
|
kind := BloodAltarDungeonKind(tokens[0].Int)
|
|
if kind != BloodAltarDungeonKindEndless && kind != BloodAltarDungeonKindUltimate {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "blood dungeon kind must be 1 (Endless) or 2 (Ultimate)"))
|
|
return
|
|
}
|
|
result.BloodAltarKind = kind
|
|
}
|
|
|
|
func parseBloodAltarMaxRounds(result *Dungeon, section RawSection) {
|
|
tokens := values(section)
|
|
if len(tokens) != 1 || !bloodAltarInt32Token(tokens[0]) || tokens[0].Int <= 0 || tokens[0].Int > 255 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "blood max round must be exactly one integer in the range 1..255"))
|
|
return
|
|
}
|
|
if result.Metadata.IntegerValues == nil {
|
|
result.Metadata.IntegerValues = make(map[string]int64)
|
|
}
|
|
result.Metadata.IntegerValues["blood max round"] = tokens[0].Int
|
|
result.BloodMaxRounds = tokens[0].Int
|
|
}
|
|
|
|
func parseTournamentDungeonSections(result *Dungeon, sections []RawSection) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
for _, section := range sections {
|
|
key := sectionKey(section.Name)
|
|
values := make([]int64, 0, len(section.Tokens))
|
|
for _, token := range section.Tokens {
|
|
if token.Kind == dnfpvf.TokenInt {
|
|
values = append(values, token.Int)
|
|
}
|
|
}
|
|
switch key {
|
|
case "/reward item rate":
|
|
if !scopeContains(section.Scope, "result card") || len(values) == 0 {
|
|
continue
|
|
}
|
|
if len(values)%4 != 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "tournament result card rows must have width 4"))
|
|
continue
|
|
}
|
|
for i := 0; i < len(values); i += 4 {
|
|
result.Metadata.TournamentResultCards = append(result.Metadata.TournamentResultCards, TournamentResultCard{
|
|
ResultKey: values[i], GoldWeight: values[i+1], ItemWeight: values[i+2], EmptyWeight: values[i+3],
|
|
})
|
|
}
|
|
case "reward item rate":
|
|
if !scopeContains(section.Scope, "result card") {
|
|
continue
|
|
}
|
|
if len(values) == 0 || len(values)%3 != 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "tournament reward item rows must have width 3"))
|
|
continue
|
|
}
|
|
for i := 0; i < len(values); i += 3 {
|
|
result.Metadata.TournamentRewardItemRates = append(result.Metadata.TournamentRewardItemRates, TournamentRewardItemRate{
|
|
ItemID: values[i], Weight: values[i+1], Count: values[i+2],
|
|
})
|
|
}
|
|
case "tournament clear reward exp":
|
|
if len(values) == 0 || len(values)%2 != 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "tournament clear reward experience rows must have width 2"))
|
|
continue
|
|
}
|
|
for i := 0; i < len(values); i += 2 {
|
|
result.Metadata.TournamentClearRewardExp = append(result.Metadata.TournamentClearRewardExp, TournamentRoundExperience{
|
|
CompletedRounds: values[i], Experience: values[i+1],
|
|
})
|
|
}
|
|
case "required item", "event required item", "added required item":
|
|
if len(values) == 0 || len(values)%3 != 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, key+" rows must have width 3"))
|
|
continue
|
|
}
|
|
target := &result.Metadata.RequiredItems
|
|
switch key {
|
|
case "event required item":
|
|
target = &result.Metadata.EventRequiredItems
|
|
case "added required item":
|
|
target = &result.Metadata.AddedRequiredItems
|
|
}
|
|
for i := 0; i < len(values); i += 3 {
|
|
if values[i] > 0 && values[i+1] > 0 {
|
|
*target = append(*target, TicketItem{ItemID: values[i], Count: values[i+1]})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseInfernoQuestIDs(result *Dungeon, section RawSection) {
|
|
values, valid := ints(section)
|
|
if !valid || len(values) == 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "inferno quest must contain one or more quest IDs"))
|
|
return
|
|
}
|
|
for _, value := range values {
|
|
if value > 0 {
|
|
result.Metadata.InfernoQuestIDs = append(result.Metadata.InfernoQuestIDs, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseMazeSection(maze *Maze, section RawSection) {
|
|
maze.SourceSections = append(maze.SourceSections, cloneRawSection(section))
|
|
key := sectionKey(section.Name)
|
|
if isClosingSection(key) {
|
|
return
|
|
}
|
|
if key == "boss room entrance condition" {
|
|
// Keep the raw source beside the typed form, matching the other complex
|
|
// maze mechanisms while avoiding its former dungeon-wide ownership.
|
|
maze.OpaqueSections = append(maze.OpaqueSections, cloneRawSection(section))
|
|
occurrences := 0
|
|
for _, source := range maze.SourceSections {
|
|
if sectionKey(source.Name) == key {
|
|
occurrences++
|
|
}
|
|
}
|
|
if occurrences != 1 {
|
|
maze.BossRoomEntranceCondition = nil
|
|
maze.Diagnostics = append(maze.Diagnostics, diagnostic(section, "maze must contain at most one boss-room entrance condition"))
|
|
return
|
|
}
|
|
condition, err := parseBossRoomEntranceCondition(section)
|
|
if err != nil {
|
|
maze.Diagnostics = append(maze.Diagnostics, diagnostic(section, err.Error()))
|
|
return
|
|
}
|
|
maze.BossRoomEntranceCondition = &condition
|
|
return
|
|
}
|
|
if _, ok := knownMazeSections[key]; !ok {
|
|
maze.UnknownSections = append(maze.UnknownSections, cloneRawSection(section))
|
|
maze.Extensions = append(maze.Extensions, structureExtension(section))
|
|
return
|
|
}
|
|
if _, ok := opaqueMazeSections[key]; ok {
|
|
maze.OpaqueSections = append(maze.OpaqueSections, cloneRawSection(section))
|
|
switch key {
|
|
case "boss map specification":
|
|
maze.BossMapSpecifications = append(maze.BossMapSpecifications, cloneRawSection(section))
|
|
case "layered map specification":
|
|
maze.LayeredMapSpecifications = append(maze.LayeredMapSpecifications, cloneRawSection(section))
|
|
}
|
|
return
|
|
}
|
|
switch key {
|
|
case "size":
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed) < 2 {
|
|
maze.Diagnostics = append(maze.Diagnostics, diagnostic(section, "expected maze width and height"))
|
|
return
|
|
}
|
|
maze.Width = OptionalInt{Value: parsed[0], Set: true}
|
|
maze.Height = OptionalInt{Value: parsed[1], Set: true}
|
|
if len(parsed) > 2 {
|
|
maze.Diagnostics = append(maze.Diagnostics, diagnostic(section, "maze size has trailing values"))
|
|
}
|
|
case "maze chance rate":
|
|
setOptionalInt(&maze.ChanceRate, section, &maze.Diagnostics)
|
|
case "inferno special":
|
|
maze.InfernoSpecial = true
|
|
case "escape hell":
|
|
setOptionalInt(&maze.EscapeHell, section, &maze.Diagnostics)
|
|
case "greed":
|
|
setText(&maze.Greed, section, &maze.Diagnostics)
|
|
case "map specification":
|
|
maze.MapSpecifications = append(maze.MapSpecifications, parseMapSpecifications(section, &maze.Diagnostics)...)
|
|
case "start map":
|
|
maze.Start = parseMazePoint(section, &maze.Diagnostics)
|
|
case "boss map":
|
|
maze.Boss = parseMazePoint(section, &maze.Diagnostics)
|
|
case "hit count":
|
|
maze.HitCount = appendInts(maze.HitCount, section, &maze.Diagnostics)
|
|
case "seal door appear rate":
|
|
setOptionalInt(&maze.SealDoorAppearRate, section, &maze.Diagnostics)
|
|
case "seal door map index":
|
|
setOptionalInt(&maze.SealDoorMapIndex, section, &maze.Diagnostics)
|
|
case "seal door pos":
|
|
maze.SealDoorPosition = appendInts(maze.SealDoorPosition, section, &maze.Diagnostics)
|
|
case "quest connection":
|
|
maze.QuestConnection = appendInts(maze.QuestConnection, section, &maze.Diagnostics)
|
|
case "special passive object item":
|
|
maze.SpecialPassiveObjectItems = append(
|
|
maze.SpecialPassiveObjectItems,
|
|
parseSpecialPassiveObjectItems(section, &maze.Diagnostics)...,
|
|
)
|
|
}
|
|
}
|
|
|
|
func parseBossRoomEntranceCondition(section RawSection) (BossRoomEntranceCondition, error) {
|
|
tokens := values(section)
|
|
position := 0
|
|
readTag := func(expected string) (string, error) {
|
|
if position >= len(tokens) {
|
|
return "", fmt.Errorf("boss-room entrance condition is missing [%s]", expected)
|
|
}
|
|
tag, ok := bossRoomEntranceTag(tokens[position])
|
|
if !ok || tag != expected {
|
|
return "", fmt.Errorf("boss-room entrance condition expected [%s] at token %d", expected, position)
|
|
}
|
|
position++
|
|
return tag, nil
|
|
}
|
|
readInt := func(label string) (int64, error) {
|
|
if position >= len(tokens) || tokens[position].Kind != dnfpvf.TokenInt {
|
|
return 0, fmt.Errorf("boss-room entrance condition expected integer %s at token %d", label, position)
|
|
}
|
|
value := tokens[position].Int
|
|
position++
|
|
return value, nil
|
|
}
|
|
|
|
action, err := readTag("clear action")
|
|
if err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
if _, err := readTag("time condition"); err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
timeFirst, err := readInt("time-condition value 1")
|
|
if err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
timeSecond, err := readInt("time-condition value 2")
|
|
if err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
if _, err := readTag("hunt monster"); err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
targetCount, err := readInt("hunt-monster target count")
|
|
if err != nil {
|
|
return BossRoomEntranceCondition{}, err
|
|
}
|
|
remaining := len(tokens) - position
|
|
if targetCount <= 0 || targetCount > int64(remaining/3) || remaining != int(targetCount)*3 {
|
|
return BossRoomEntranceCondition{}, fmt.Errorf(
|
|
"boss-room entrance condition hunt-monster count %d does not match %d trailing values",
|
|
targetCount, remaining,
|
|
)
|
|
}
|
|
|
|
condition := BossRoomEntranceCondition{
|
|
Action: action,
|
|
TimeCondition: []int64{timeFirst, timeSecond},
|
|
HuntMonsters: make([]BossRoomEntranceHuntMonster, 0, targetCount),
|
|
}
|
|
for targetIndex := int64(0); targetIndex < targetCount; targetIndex++ {
|
|
targetID, targetErr := readInt(fmt.Sprintf("hunt-monster target %d id", targetIndex))
|
|
if targetErr != nil {
|
|
return BossRoomEntranceCondition{}, targetErr
|
|
}
|
|
opaque, opaqueErr := readInt(fmt.Sprintf("hunt-monster target %d opaque value", targetIndex))
|
|
if opaqueErr != nil {
|
|
return BossRoomEntranceCondition{}, opaqueErr
|
|
}
|
|
count, countErr := readInt(fmt.Sprintf("hunt-monster target %d count", targetIndex))
|
|
if countErr != nil {
|
|
return BossRoomEntranceCondition{}, countErr
|
|
}
|
|
if targetID <= 0 || count <= 0 {
|
|
return BossRoomEntranceCondition{}, fmt.Errorf(
|
|
"boss-room entrance condition hunt-monster target %d must have positive id and count",
|
|
targetIndex,
|
|
)
|
|
}
|
|
condition.HuntMonsters = append(condition.HuntMonsters, BossRoomEntranceHuntMonster{
|
|
TargetID: targetID,
|
|
Opaque: opaque,
|
|
Count: count,
|
|
})
|
|
}
|
|
return condition, nil
|
|
}
|
|
|
|
func bossRoomEntranceTag(token dnfpvf.Token) (string, bool) {
|
|
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
|
|
return "", false
|
|
}
|
|
value := strings.TrimSpace(token.Value)
|
|
if len(value) < 3 || value[0] != '[' || value[len(value)-1] != ']' {
|
|
return "", false
|
|
}
|
|
value = sectionKey(value[1 : len(value)-1])
|
|
return value, value != ""
|
|
}
|
|
|
|
func parseSpecialPassiveObjectItems(section RawSection, diagnostics *[]Diagnostic) []SpecialPassiveObjectItem {
|
|
values, valid := ints(section)
|
|
if !valid {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected integer passive-object item groups"))
|
|
return nil
|
|
}
|
|
// Record layout (86JP PvfLib DungeonFile.ParseSpecialPassiveObjectItem and
|
|
// the official dungeonsample_new_drop.dgn): `<group label> <level override>
|
|
// <item count>` followed by `<item id> <drop rate>` pairs. A map [item]
|
|
// child selects a record by its position in this vector, so Index is the
|
|
// record position; the live PVF lists records sequentially (including
|
|
// empty ones such as `0 -1 0`) and the leading label matches the position
|
|
// everywhere except dungeon 315, whose single record is labelled 1 while
|
|
// its maps roll code 0.
|
|
position := 0
|
|
groupIndex := int64(0)
|
|
var rows []SpecialPassiveObjectItem
|
|
for position < len(values) {
|
|
if position+3 > len(values) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("truncated passive-object item group at token %d", position)))
|
|
return rows
|
|
}
|
|
levelOverride, count := values[position+1], values[position+2]
|
|
position += 3
|
|
if count < 0 || count > int64((len(values)-position)/2) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("invalid passive-object item count %d in group %d", count, groupIndex)))
|
|
return rows
|
|
}
|
|
for itemIndex := int64(0); itemIndex < count; itemIndex++ {
|
|
itemID, dropRate := values[position], values[position+1]
|
|
position += 2
|
|
if itemID <= 0 || dropRate < 0 || dropRate > 10000 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("invalid passive-object item in group %d at item %d", groupIndex, itemIndex)))
|
|
continue
|
|
}
|
|
rows = append(rows, SpecialPassiveObjectItem{
|
|
Index: groupIndex, LevelOverride: levelOverride,
|
|
ItemID: itemID, DropRate: dropRate,
|
|
})
|
|
}
|
|
groupIndex++
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func parseMapSpecifications(section RawSection, diagnostics *[]Diagnostic) []MapSpecification {
|
|
tokens := values(section)
|
|
var out []MapSpecification
|
|
for i := 0; i < len(tokens); {
|
|
if !isText(tokens[i]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("expected specification type at token %d", i)))
|
|
i++
|
|
continue
|
|
}
|
|
if i+3 >= len(tokens) || !areInts(tokens[i+1:i+4]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed map specification at token %d", i)))
|
|
i++
|
|
continue
|
|
}
|
|
spec := MapSpecification{
|
|
Type: tokens[i].Value,
|
|
Coordinate: Point{X: tokens[i+1].Int, Y: tokens[i+2].Int},
|
|
MapIDs: []int64{tokens[i+3].Int},
|
|
SectionOccurrence: section.Occurrence,
|
|
}
|
|
i += 4
|
|
for i < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt {
|
|
spec.MapIDs = append(spec.MapIDs, tokens[i].Int)
|
|
i++
|
|
}
|
|
out = append(out, spec)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseMazeComplexSections(maze *Maze) {
|
|
for pos := 0; pos < len(maze.SourceSections); pos++ {
|
|
section := maze.SourceSections[pos]
|
|
switch sectionKey(section.Name) {
|
|
case "boss map specification":
|
|
maze.BossSpecifications = append(
|
|
maze.BossSpecifications,
|
|
parseTypedOrImplicitSpecifications(section, "boss", &maze.Diagnostics)...,
|
|
)
|
|
case "layered map specification":
|
|
maze.LayeredSpecifications = append(
|
|
maze.LayeredSpecifications,
|
|
parseTypedOrImplicitSpecifications(section, "layered", &maze.Diagnostics)...,
|
|
)
|
|
case "randomized object creation":
|
|
script, next := parseRandomizedObjectScript(maze.SourceSections, pos, &maze.Diagnostics)
|
|
maze.RandomizedObjects = append(maze.RandomizedObjects, script)
|
|
pos = next
|
|
case "clear condition":
|
|
conditions, next := parseClearConditions(maze.SourceSections, pos, &maze.Diagnostics)
|
|
maze.ClearConditions = append(maze.ClearConditions, conditions...)
|
|
pos = next
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseTypedOrImplicitSpecifications(section RawSection, implicitType string, diagnostics *[]Diagnostic) []MapSpecification {
|
|
tokens := values(section)
|
|
if len(tokens) > 0 && isText(tokens[0]) {
|
|
return parseMapSpecifications(section, diagnostics)
|
|
}
|
|
if len(tokens) < 3 || !areInts(tokens[:3]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected x/y/map-id specification values"))
|
|
return nil
|
|
}
|
|
mapIDs := make([]int64, 0, len(tokens)-2)
|
|
for _, token := range tokens[2:] {
|
|
if token.Kind != dnfpvf.TokenInt {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "implicit specification contains non-integer map id"))
|
|
return nil
|
|
}
|
|
mapIDs = append(mapIDs, token.Int)
|
|
}
|
|
return []MapSpecification{{
|
|
Type: implicitType, Coordinate: Point{X: tokens[0].Int, Y: tokens[1].Int},
|
|
MapIDs: mapIDs, SectionOccurrence: section.Occurrence,
|
|
}}
|
|
}
|
|
|
|
func parseRandomizedObjectScript(sections []RawSection, start int, diagnostics *[]Diagnostic) (RandomizedObjectScript, int) {
|
|
script := RandomizedObjectScript{}
|
|
end := start
|
|
for pos := start; pos < len(sections); pos++ {
|
|
section := sections[pos]
|
|
key := sectionKey(section.Name)
|
|
script.SourceSections = append(script.SourceSections, cloneRawSection(section))
|
|
end = pos
|
|
if key == "/randomized object creation" {
|
|
break
|
|
}
|
|
switch key {
|
|
case "randomized object creation":
|
|
case "select":
|
|
setOptionalInt(&script.SelectCount, section, diagnostics)
|
|
case "regenerate":
|
|
setOptionalInt(&script.Regenerate, section, diagnostics)
|
|
case "minimap icon":
|
|
setOptionalInt(&script.MinimapIcon, section, diagnostics)
|
|
case "object":
|
|
object, objectEnd := parseRidableObject(sections, pos, diagnostics)
|
|
script.Objects = append(script.Objects, object)
|
|
for nested := pos + 1; nested <= objectEnd && nested < len(sections); nested++ {
|
|
script.SourceSections = append(script.SourceSections, cloneRawSection(sections[nested]))
|
|
}
|
|
pos = objectEnd
|
|
end = objectEnd
|
|
default:
|
|
if !isClosingSection(key) {
|
|
script.UnknownSections = append(script.UnknownSections, cloneRawSection(section))
|
|
script.Extensions = append(script.Extensions, structureExtension(section))
|
|
}
|
|
}
|
|
}
|
|
return script, end
|
|
}
|
|
|
|
func parseRidableObject(sections []RawSection, start int, diagnostics *[]Diagnostic) (RidableObject, int) {
|
|
object := RidableObject{}
|
|
end := start
|
|
for pos := start; pos < len(sections); pos++ {
|
|
section := sections[pos]
|
|
key := sectionKey(section.Name)
|
|
object.SourceSections = append(object.SourceSections, cloneRawSection(section))
|
|
end = pos
|
|
if key == "/object" {
|
|
break
|
|
}
|
|
switch key {
|
|
case "object", "team":
|
|
case "map":
|
|
object.MazeCoordinate = parsePoint(section, "maze map coordinate", diagnostics)
|
|
case "index":
|
|
setOptionalInt(&object.ObjectIndex, section, diagnostics)
|
|
case "pos":
|
|
object.Position = parsePoint(section, "object position", diagnostics)
|
|
case "monster", "neutral", "character":
|
|
object.Team = key
|
|
default:
|
|
if !isClosingSection(key) {
|
|
object.UnknownSections = append(object.UnknownSections, cloneRawSection(section))
|
|
object.Extensions = append(object.Extensions, structureExtension(section))
|
|
}
|
|
}
|
|
}
|
|
return object, end
|
|
}
|
|
|
|
func parseClearConditions(sections []RawSection, start int, diagnostics *[]Diagnostic) ([]ClearCondition, int) {
|
|
var out []ClearCondition
|
|
end := start
|
|
for pos := start + 1; pos < len(sections); pos++ {
|
|
section := sections[pos]
|
|
key := sectionKey(section.Name)
|
|
end = pos
|
|
if key == "/clear condition" {
|
|
break
|
|
}
|
|
switch key {
|
|
case "destroy object", "seeking", "hunt monster", "hunt apc", "hunt boss":
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%2 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected clear-condition target/count pairs"))
|
|
}
|
|
for index := 0; index+1 < len(parsed); index += 2 {
|
|
out = append(out, ClearCondition{Type: key, TargetID: parsed[index], Count: parsed[index+1]})
|
|
}
|
|
}
|
|
}
|
|
return out, end
|
|
}
|
|
|
|
func parsePoint(section RawSection, label string, diagnostics *[]Diagnostic) *Point {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed) < 2 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected "+label+" x/y values"))
|
|
return nil
|
|
}
|
|
if len(parsed) > 2 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, label+" has trailing values"))
|
|
}
|
|
return &Point{X: parsed[0], Y: parsed[1]}
|
|
}
|
|
|
|
func parseMazePoint(section RawSection, diagnostics *[]Diagnostic) *MazePoint {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed) < 2 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected maze x/y coordinates"))
|
|
return nil
|
|
}
|
|
return &MazePoint{X: parsed[0], Y: parsed[1], Params: append([]int64(nil), parsed[2:]...)}
|
|
}
|
|
|
|
func setOptionalNumber(target *OptionalNumber, section RawSection, diagnostics *[]Diagnostic) {
|
|
value, ok := firstNumber(section)
|
|
if !ok {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected numeric value"))
|
|
return
|
|
}
|
|
target.Value = value
|
|
target.Set = true
|
|
}
|
|
|
|
func firstNumber(section RawSection) (float64, bool) {
|
|
for _, token := range section.Tokens {
|
|
switch token.Kind {
|
|
case dnfpvf.TokenInt:
|
|
return float64(token.Int), true
|
|
case dnfpvf.TokenFloat:
|
|
return token.Float, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func structureExtension(section RawSection) ExtensionSection {
|
|
extension := ExtensionSection{
|
|
Name: section.Name, Occurrence: section.Occurrence,
|
|
Scope: append([]string(nil), section.Scope...), Block: section.Block,
|
|
Tokens: append([]dnfpvf.Token(nil), section.Tokens...),
|
|
}
|
|
nonSymbols := 0
|
|
allIntegers := true
|
|
allNumbers := true
|
|
allTexts := true
|
|
for _, token := range section.Tokens {
|
|
switch token.Kind {
|
|
case dnfpvf.TokenInt:
|
|
nonSymbols++
|
|
extension.Integers = append(extension.Integers, token.Int)
|
|
extension.Numbers = append(extension.Numbers, float64(token.Int))
|
|
allTexts = false
|
|
case dnfpvf.TokenFloat:
|
|
nonSymbols++
|
|
extension.Numbers = append(extension.Numbers, token.Float)
|
|
allIntegers = false
|
|
allTexts = false
|
|
case dnfpvf.TokenString, dnfpvf.TokenIdent:
|
|
nonSymbols++
|
|
extension.Texts = append(extension.Texts, token.Value)
|
|
allIntegers = false
|
|
allNumbers = false
|
|
case dnfpvf.TokenSymbol:
|
|
extension.Symbols = append(extension.Symbols, token.Value)
|
|
}
|
|
}
|
|
switch {
|
|
case nonSymbols == 0 && len(extension.Symbols) == 0:
|
|
extension.Kind = SectionFlag
|
|
case allIntegers:
|
|
extension.Kind = SectionIntegers
|
|
case allNumbers:
|
|
extension.Kind = SectionNumbers
|
|
case allTexts:
|
|
extension.Kind = SectionTexts
|
|
default:
|
|
extension.Kind = SectionMixed
|
|
}
|
|
return extension
|
|
}
|