服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
976 lines
32 KiB
Go
976 lines
32 KiB
Go
package worldmap
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrResolverTableRequired = errors.New("dnf worldmap resolver table is required")
|
|
ErrDungeonNotIndexed = errors.New("dnf dungeon is not indexed")
|
|
ErrMazeNotFound = errors.New("dnf dungeon maze is not found")
|
|
ErrMapReferenceMissing = errors.New("dnf dungeon map reference is missing")
|
|
ErrMapUnresolved = errors.New("dnf dungeon map is unresolved")
|
|
ErrMapAmbiguous = errors.New("dnf dungeon map is ambiguous")
|
|
ErrMapPoolEmpty = errors.New("dnf dungeon map pool is empty")
|
|
ErrMapPoolChoiceInvalid = errors.New("dnf dungeon map pool choice is invalid")
|
|
ErrLayeredSpecMissing = errors.New("dnf dungeon layered map specification is missing")
|
|
ErrLayeredSpecAmbiguous = errors.New("dnf dungeon layered map specification is ambiguous")
|
|
ErrLayeredIndexInvalid = errors.New("dnf dungeon layered map index is invalid")
|
|
ErrStoryStageMissing = errors.New("dnf dungeon story stage is missing")
|
|
ErrStoryStageIndexInvalid = errors.New("dnf dungeon story stage index is invalid")
|
|
)
|
|
|
|
type MapFileType string
|
|
|
|
const (
|
|
MapFileNormal MapFileType = "normal"
|
|
MapFileStart MapFileType = "start"
|
|
MapFileBoss MapFileType = "boss"
|
|
MapFileNamed MapFileType = "named"
|
|
MapFileEnd MapFileType = "end"
|
|
MapFileHidden MapFileType = "hidden"
|
|
MapFileQuest MapFileType = "quest"
|
|
MapFileDefault MapFileType = "default"
|
|
)
|
|
|
|
type ResolutionSource string
|
|
|
|
const (
|
|
ResolutionMapSpecification ResolutionSource = "map_specification"
|
|
ResolutionDungeonOwnership ResolutionSource = "dungeon_ownership"
|
|
ResolutionDirectoryPath ResolutionSource = "directory_coordinate"
|
|
ResolutionTypePool ResolutionSource = "type_pool"
|
|
ResolutionHellParty ResolutionSource = "hell_party_seal_door"
|
|
)
|
|
|
|
type ResolveRequest struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
MazeIndex int `json:"maze_index"`
|
|
X int64 `json:"x"`
|
|
Y int64 `json:"y"`
|
|
}
|
|
|
|
type ResolvedMap struct {
|
|
Map Map `json:"map"`
|
|
Source ResolutionSource `json:"source"`
|
|
SpecificationType string `json:"specification_type,omitempty"`
|
|
}
|
|
|
|
// DungeonStoryStage is one client-side PVF map-candidate advance in an
|
|
// activity story maze. Index and map order are derived from the repeated
|
|
// [boss map] coordinate sequence and the matching [map specification] rows.
|
|
type DungeonStoryStage struct {
|
|
Index int `json:"index"`
|
|
Coordinate RoomCoordinate `json:"coordinate"`
|
|
MapID int64 `json:"map_id"`
|
|
SpecificationType string `json:"specification_type"`
|
|
}
|
|
|
|
type DungeonMapIndexSnapshot struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
Mazes int `json:"mazes"`
|
|
Specifications int `json:"specifications"`
|
|
OwnershipEntries int `json:"ownership_entries"`
|
|
CoordinateEntries int `json:"coordinate_entries"`
|
|
PathEntries int `json:"path_entries"`
|
|
}
|
|
|
|
type Resolver struct {
|
|
table *Table
|
|
byDungeon map[int64]dungeonMapIndex
|
|
}
|
|
|
|
type coordinateKey struct {
|
|
x int64
|
|
y int64
|
|
}
|
|
|
|
type indexedMap struct {
|
|
mapPos int
|
|
fileType MapFileType
|
|
specificationType string
|
|
}
|
|
|
|
type mazeMapIndex struct {
|
|
bySpecification map[coordinateKey][]indexedMap
|
|
byOwnership map[coordinateKey][]indexedMap
|
|
byCoordinate map[coordinateKey][]indexedMap
|
|
byPool map[MapFileType][]indexedMap
|
|
byPath map[string]indexedMap
|
|
layeredByRoom map[coordinateKey]map[int]struct{}
|
|
layeredMaps map[int]struct{}
|
|
storyStages []indexedStoryStage
|
|
storyMapsByRoom map[coordinateKey]map[int]struct{}
|
|
specifications int
|
|
}
|
|
|
|
type indexedStoryStage struct {
|
|
coordinate coordinateKey
|
|
mapPos int
|
|
specificationType string
|
|
}
|
|
|
|
type dungeonMapIndex struct {
|
|
dungeonPos int
|
|
mazes []mazeMapIndex
|
|
}
|
|
|
|
var mapCoordinatePattern = regexp.MustCompile(`\((-?\d+)[,.](-?\d+)\)`)
|
|
|
|
func NewResolver(table *Table) (*Resolver, error) {
|
|
if table == nil {
|
|
return nil, ErrResolverTableRequired
|
|
}
|
|
resolver := &Resolver{table: table, byDungeon: make(map[int64]dungeonMapIndex, len(table.dungeons))}
|
|
mapsByDirectory := indexMapsByDirectory(table.maps)
|
|
mapsByDungeon := indexMapsByDungeon(table.maps)
|
|
for dungeonPos, dungeon := range table.dungeons {
|
|
if _, exists := resolver.byDungeon[dungeon.ID]; exists {
|
|
continue
|
|
}
|
|
index := dungeonMapIndex{dungeonPos: dungeonPos, mazes: make([]mazeMapIndex, len(dungeon.Mazes))}
|
|
for mazePos, maze := range dungeon.Mazes {
|
|
mazeIndex, err := buildMazeMapIndex(table, dungeon, maze, mapsByDirectory, mapsByDungeon[dungeon.ID])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
index.mazes[mazePos] = mazeIndex
|
|
}
|
|
resolver.byDungeon[dungeon.ID] = index
|
|
}
|
|
return resolver, nil
|
|
}
|
|
|
|
func buildMazeMapIndex(
|
|
table *Table,
|
|
dungeon Dungeon,
|
|
maze Maze,
|
|
mapsByDirectory map[string][]int,
|
|
ownedMaps []int,
|
|
) (mazeMapIndex, error) {
|
|
index := mazeMapIndex{
|
|
bySpecification: make(map[coordinateKey][]indexedMap),
|
|
byOwnership: make(map[coordinateKey][]indexedMap),
|
|
byCoordinate: make(map[coordinateKey][]indexedMap),
|
|
byPool: make(map[MapFileType][]indexedMap),
|
|
byPath: make(map[string]indexedMap),
|
|
layeredByRoom: make(map[coordinateKey]map[int]struct{}),
|
|
layeredMaps: make(map[int]struct{}),
|
|
storyMapsByRoom: make(map[coordinateKey]map[int]struct{}),
|
|
}
|
|
if err := buildMazeStoryStageIndex(table, dungeon, maze, &index); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
directories := make(map[string]struct{})
|
|
for _, specification := range maze.MapSpecifications {
|
|
if isLayeredSpecification(specification) {
|
|
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
continue
|
|
}
|
|
if err := addSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
}
|
|
for _, specification := range maze.BossSpecifications {
|
|
if isLayeredSpecification(specification) {
|
|
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
continue
|
|
}
|
|
if err := addSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
}
|
|
for _, specification := range maze.LayeredSpecifications {
|
|
if err := addLayeredSpecificationToMazeIndex(table, dungeon, maze, specification, &index, directories); err != nil {
|
|
return mazeMapIndex{}, err
|
|
}
|
|
}
|
|
filterStoryStageSpecificationCandidates(&index)
|
|
for directory := range directories {
|
|
for _, mapPos := range mapsByDirectory[directory] {
|
|
mapValue := table.maps[mapPos]
|
|
ref := indexedMap{mapPos: mapPos, fileType: ClassifyMapFileType(mapValue.Path)}
|
|
index.byPath[pathKey(mapValue.Path)] = ref
|
|
// A start room must have a map: the client cannot load the entrance
|
|
// without one, while an unmapped non-start cell is legal (see
|
|
// dungeon_layout.go's ErrMapPoolEmpty branch). Some dungeons place
|
|
// [start map] on a coordinate that no [map specification] entry covers
|
|
// and own no [dungeon] maps at all, so bySpecification, byOwnership and
|
|
// byCoordinate all miss and the old byPool (owned, coordinate-less maps
|
|
// only) was empty too -- Anton 219 blackvolcano declares start (0,1)
|
|
// while its resources sit at (0,0)..(6,0) under map/219_blackvolcano,
|
|
// which made the gate permanently unenterable ("build dungeon layout:
|
|
// assign dungeon room map 0:1: ... pool is empty: dungeon=219
|
|
// maze=0 file_type=start", live 2026-09-16T15:01:58Z). Publishing the
|
|
// referenced directory's start map into byPool keeps that last-resort
|
|
// fallback available without inventing maps for non-start cells.
|
|
if ref.fileType == MapFileStart {
|
|
index.byPool[MapFileStart] = appendUniqueIndexedMap(index.byPool[MapFileStart], ref)
|
|
}
|
|
x, y, ok := ParseMapFileCoordinate(mapValue.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key := coordinateKey{x: x, y: y}
|
|
if index.isLayeredMapAt(key, mapPos) {
|
|
continue
|
|
}
|
|
if index.isStoryMapAt(key, mapPos) {
|
|
continue
|
|
}
|
|
index.byCoordinate[key] = appendUniqueIndexedMap(index.byCoordinate[key], ref)
|
|
}
|
|
}
|
|
for _, mapPos := range ownedMaps {
|
|
mapValue := table.maps[mapPos]
|
|
ref := indexedMap{mapPos: mapPos, fileType: ClassifyMapFileType(mapValue.Path)}
|
|
index.byPath[pathKey(mapValue.Path)] = ref
|
|
x, y, ok := ParseMapFileCoordinate(mapValue.Path)
|
|
if !ok {
|
|
if _, layered := index.layeredMaps[mapPos]; layered {
|
|
continue
|
|
}
|
|
index.byPool[ref.fileType] = appendUniqueIndexedMap(index.byPool[ref.fileType], ref)
|
|
continue
|
|
}
|
|
key := coordinateKey{x: x, y: y}
|
|
if index.isLayeredMapAt(key, mapPos) {
|
|
continue
|
|
}
|
|
if index.isStoryMapAt(key, mapPos) {
|
|
continue
|
|
}
|
|
index.byOwnership[key] = appendUniqueIndexedMap(index.byOwnership[key], ref)
|
|
}
|
|
return index, nil
|
|
}
|
|
|
|
func buildMazeStoryStageIndex(
|
|
table *Table,
|
|
dungeon Dungeon,
|
|
maze Maze,
|
|
index *mazeMapIndex,
|
|
) error {
|
|
coordinates := mazeBossCoordinates(maze.Boss)
|
|
if len(coordinates) < 2 || !hasRepeatedRoomCoordinate(coordinates) || !mazeQuestConnected(maze) {
|
|
return nil
|
|
}
|
|
specificationsByRoom := make(map[coordinateKey][]MapSpecification)
|
|
appendSpecifications := func(specifications []MapSpecification) {
|
|
for _, specification := range specifications {
|
|
if isLayeredSpecification(specification) || len(specification.MapIDs) == 0 {
|
|
continue
|
|
}
|
|
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
|
|
specificationsByRoom[key] = append(specificationsByRoom[key], specification)
|
|
}
|
|
}
|
|
appendSpecifications(maze.MapSpecifications)
|
|
appendSpecifications(maze.BossSpecifications)
|
|
used := make(map[coordinateKey]int)
|
|
stages := make([]indexedStoryStage, 0, len(coordinates))
|
|
for _, coordinate := range coordinates {
|
|
key := coordinateKey{x: coordinate.X, y: coordinate.Y}
|
|
position := used[key]
|
|
specifications := specificationsByRoom[key]
|
|
if position >= len(specifications) {
|
|
return nil
|
|
}
|
|
specification := specifications[position]
|
|
used[key] = position + 1
|
|
mapID := specification.MapIDs[0]
|
|
mapPos, ok := table.mapByID[mapID]
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d story_stage=%d coordinate=(%d,%d) map_id=%d",
|
|
ErrMapReferenceMissing, dungeon.ID, maze.Index, len(stages), key.x, key.y, mapID,
|
|
)
|
|
}
|
|
stages = append(stages, indexedStoryStage{
|
|
coordinate: key, mapPos: mapPos, specificationType: specification.Type,
|
|
})
|
|
}
|
|
for _, stage := range stages {
|
|
if index.storyMapsByRoom[stage.coordinate] == nil {
|
|
index.storyMapsByRoom[stage.coordinate] = make(map[int]struct{})
|
|
}
|
|
index.storyMapsByRoom[stage.coordinate][stage.mapPos] = struct{}{}
|
|
}
|
|
index.storyStages = stages
|
|
return nil
|
|
}
|
|
|
|
func hasRepeatedRoomCoordinate(coordinates []RoomCoordinate) bool {
|
|
seen := make(map[RoomCoordinate]struct{}, len(coordinates))
|
|
for _, coordinate := range coordinates {
|
|
if _, ok := seen[coordinate]; ok {
|
|
return true
|
|
}
|
|
seen[coordinate] = struct{}{}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func filterStoryStageSpecificationCandidates(index *mazeMapIndex) {
|
|
if index == nil || len(index.storyStages) == 0 {
|
|
return
|
|
}
|
|
firstStageByRoom := make(map[coordinateKey]int)
|
|
for _, stage := range index.storyStages {
|
|
if _, exists := firstStageByRoom[stage.coordinate]; !exists {
|
|
firstStageByRoom[stage.coordinate] = stage.mapPos
|
|
}
|
|
}
|
|
for key, storyMaps := range index.storyMapsByRoom {
|
|
candidates := index.bySpecification[key]
|
|
ordinary := make([]indexedMap, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if _, story := storyMaps[candidate.mapPos]; !story {
|
|
ordinary = appendUniqueIndexedMap(ordinary, candidate)
|
|
}
|
|
}
|
|
if len(ordinary) != 0 {
|
|
index.bySpecification[key] = ordinary
|
|
continue
|
|
}
|
|
fallbackMapPos, ok := firstStageByRoom[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, candidate := range candidates {
|
|
if candidate.mapPos == fallbackMapPos {
|
|
index.bySpecification[key] = []indexedMap{candidate}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func isLayeredSpecification(specification MapSpecification) bool {
|
|
return sectionKey(strings.Trim(specification.Type, "[]")) == "layered"
|
|
}
|
|
|
|
func addLayeredSpecificationToMazeIndex(
|
|
table *Table,
|
|
dungeon Dungeon,
|
|
maze Maze,
|
|
specification MapSpecification,
|
|
index *mazeMapIndex,
|
|
directories map[string]struct{},
|
|
) error {
|
|
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
|
|
index.specifications++
|
|
if index.layeredByRoom[key] == nil {
|
|
index.layeredByRoom[key] = make(map[int]struct{})
|
|
}
|
|
for _, mapID := range specification.MapIDs {
|
|
mapPos, ok := table.mapByID[mapID]
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_id=%d",
|
|
ErrMapReferenceMissing, dungeon.ID, maze.Index, key.x, key.y, mapID,
|
|
)
|
|
}
|
|
index.layeredByRoom[key][mapPos] = struct{}{}
|
|
index.layeredMaps[mapPos] = struct{}{}
|
|
directory := pathKey(path.Dir(strings.ReplaceAll(table.maps[mapPos].Path, "\\", "/")))
|
|
if directory != "" && directory != "." {
|
|
directories[directory] = struct{}{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (index mazeMapIndex) isLayeredMapAt(key coordinateKey, mapPos int) bool {
|
|
_, ok := index.layeredByRoom[key][mapPos]
|
|
return ok
|
|
}
|
|
|
|
func (index mazeMapIndex) isStoryMapAt(key coordinateKey, mapPos int) bool {
|
|
_, ok := index.storyMapsByRoom[key][mapPos]
|
|
return ok
|
|
}
|
|
|
|
func indexMapsByDirectory(maps []Map) map[string][]int {
|
|
out := make(map[string][]int)
|
|
for mapPos, mapValue := range maps {
|
|
directory := pathKey(path.Dir(strings.ReplaceAll(mapValue.Path, "\\", "/")))
|
|
out[directory] = append(out[directory], mapPos)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func indexMapsByDungeon(maps []Map) map[int64][]int {
|
|
out := make(map[int64][]int)
|
|
for mapPos, mapValue := range maps {
|
|
for _, dungeonID := range mapValue.DungeonIDs {
|
|
out[dungeonID] = append(out[dungeonID], mapPos)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func addSpecificationToMazeIndex(
|
|
table *Table,
|
|
dungeon Dungeon,
|
|
maze Maze,
|
|
specification MapSpecification,
|
|
index *mazeMapIndex,
|
|
directories map[string]struct{},
|
|
) error {
|
|
key := coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}
|
|
index.specifications++
|
|
for _, mapID := range specification.MapIDs {
|
|
mapPos, ok := table.mapByID[mapID]
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_id=%d",
|
|
ErrMapReferenceMissing, dungeon.ID, maze.Index, key.x, key.y, mapID,
|
|
)
|
|
}
|
|
mapValue := table.maps[mapPos]
|
|
ref := indexedMap{
|
|
mapPos: mapPos, fileType: specificationFileType(specification.Type),
|
|
specificationType: specification.Type,
|
|
}
|
|
index.bySpecification[key] = appendUniqueIndexedMap(index.bySpecification[key], ref)
|
|
index.byPath[pathKey(mapValue.Path)] = ref
|
|
directory := pathKey(path.Dir(strings.ReplaceAll(mapValue.Path, "\\", "/")))
|
|
if directory != "" && directory != "." {
|
|
directories[directory] = struct{}{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Resolver) Resolve(request ResolveRequest) (ResolvedMap, error) {
|
|
candidates, source, err := r.resolveCandidateSet(request)
|
|
if err != nil {
|
|
return ResolvedMap{}, err
|
|
}
|
|
return r.resolveCandidates(request, candidates, source)
|
|
}
|
|
|
|
// ResolveLayered resolves exactly one PVF-declared layered map at a maze
|
|
// coordinate. layerIndex is positional in the specification's MapIDs list;
|
|
// directory ownership and filename-coordinate fallbacks are deliberately not
|
|
// consulted. Both inline `layered` rows in [map specification] and typed
|
|
// [layered map specification] rows are explicit PVF declarations.
|
|
func (r *Resolver) ResolveLayered(
|
|
dungeonID int64,
|
|
mazeIndex int,
|
|
coordinate RoomCoordinate,
|
|
layerIndex int,
|
|
) (ResolvedMap, error) {
|
|
if r == nil || r.table == nil {
|
|
return ResolvedMap{}, ErrResolverTableRequired
|
|
}
|
|
dungeonIndex, ok := r.byDungeon[dungeonID]
|
|
if !ok {
|
|
return ResolvedMap{}, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
|
|
}
|
|
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
|
|
return ResolvedMap{}, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
|
|
}
|
|
dungeon := r.table.dungeons[dungeonIndex.dungeonPos]
|
|
maze := dungeon.Mazes[mazeIndex]
|
|
specifications := explicitLayeredSpecificationsAt(maze, coordinate)
|
|
if len(specifications) == 0 {
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=%s",
|
|
ErrLayeredSpecMissing,
|
|
dungeonID,
|
|
mazeIndex,
|
|
coordinate,
|
|
)
|
|
}
|
|
if len(specifications) != 1 {
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=%s specifications=%d",
|
|
ErrLayeredSpecAmbiguous,
|
|
dungeonID,
|
|
mazeIndex,
|
|
coordinate,
|
|
len(specifications),
|
|
)
|
|
}
|
|
specification := specifications[0]
|
|
if layerIndex < 0 || layerIndex >= len(specification.MapIDs) {
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=%s layer=%d count=%d",
|
|
ErrLayeredIndexInvalid,
|
|
dungeonID,
|
|
mazeIndex,
|
|
coordinate,
|
|
layerIndex,
|
|
len(specification.MapIDs),
|
|
)
|
|
}
|
|
mapID := specification.MapIDs[layerIndex]
|
|
mapPos, ok := r.table.mapByID[mapID]
|
|
if !ok {
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=%s layer=%d map_id=%d",
|
|
ErrMapReferenceMissing,
|
|
dungeonID,
|
|
mazeIndex,
|
|
coordinate,
|
|
layerIndex,
|
|
mapID,
|
|
)
|
|
}
|
|
return ResolvedMap{
|
|
Map: cloneMap(r.table.maps[mapPos]),
|
|
Source: ResolutionMapSpecification,
|
|
SpecificationType: "layered",
|
|
}, nil
|
|
}
|
|
|
|
// StoryStages returns the ordered activity-story candidate chain declared by
|
|
// the active maze. A maze is treated as staged only when its quest-connected
|
|
// [boss map] sequence repeats a coordinate and every occurrence can be paired
|
|
// with a matching non-layered map specification in declaration order.
|
|
func (r *Resolver) StoryStages(dungeonID int64, mazeIndex int) ([]DungeonStoryStage, error) {
|
|
index, err := r.mazeIndex(dungeonID, mazeIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(index.storyStages) == 0 {
|
|
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrStoryStageMissing, dungeonID, mazeIndex)
|
|
}
|
|
out := make([]DungeonStoryStage, len(index.storyStages))
|
|
for stageIndex, stage := range index.storyStages {
|
|
out[stageIndex] = DungeonStoryStage{
|
|
Index: stageIndex,
|
|
Coordinate: RoomCoordinate{X: stage.coordinate.x, Y: stage.coordinate.y},
|
|
MapID: r.table.maps[stage.mapPos].ID,
|
|
SpecificationType: stage.specificationType,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Resolver) ResolveStoryStage(dungeonID int64, mazeIndex int, stageIndex int) (ResolvedMap, DungeonStoryStage, error) {
|
|
index, err := r.mazeIndex(dungeonID, mazeIndex)
|
|
if err != nil {
|
|
return ResolvedMap{}, DungeonStoryStage{}, err
|
|
}
|
|
if len(index.storyStages) == 0 {
|
|
return ResolvedMap{}, DungeonStoryStage{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d", ErrStoryStageMissing, dungeonID, mazeIndex,
|
|
)
|
|
}
|
|
if stageIndex < 0 || stageIndex >= len(index.storyStages) {
|
|
return ResolvedMap{}, DungeonStoryStage{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d stage=%d count=%d",
|
|
ErrStoryStageIndexInvalid, dungeonID, mazeIndex, stageIndex, len(index.storyStages),
|
|
)
|
|
}
|
|
stage := index.storyStages[stageIndex]
|
|
descriptor := DungeonStoryStage{
|
|
Index: stageIndex,
|
|
Coordinate: RoomCoordinate{X: stage.coordinate.x, Y: stage.coordinate.y},
|
|
MapID: r.table.maps[stage.mapPos].ID,
|
|
SpecificationType: stage.specificationType,
|
|
}
|
|
return ResolvedMap{
|
|
Map: cloneMap(r.table.maps[stage.mapPos]),
|
|
Source: ResolutionMapSpecification,
|
|
SpecificationType: stage.specificationType,
|
|
}, descriptor, nil
|
|
}
|
|
|
|
func explicitLayeredSpecificationsAt(maze Maze, coordinate RoomCoordinate) []MapSpecification {
|
|
specifications := make([]MapSpecification, 0, 1)
|
|
appendMatching := func(values []MapSpecification, requireLayeredType bool) {
|
|
for _, specification := range values {
|
|
if requireLayeredType && sectionKey(strings.Trim(specification.Type, "[]")) != "layered" {
|
|
continue
|
|
}
|
|
if specification.Coordinate.X != coordinate.X || specification.Coordinate.Y != coordinate.Y {
|
|
continue
|
|
}
|
|
specifications = append(specifications, specification)
|
|
}
|
|
}
|
|
appendMatching(maze.MapSpecifications, true)
|
|
appendMatching(maze.LayeredSpecifications, false)
|
|
return specifications
|
|
}
|
|
|
|
func (r *Resolver) ResolveCandidates(request ResolveRequest) ([]ResolvedMap, error) {
|
|
candidates, source, err := r.resolveCandidateSet(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates = uniqueIndexedMaps(candidates)
|
|
out := make([]ResolvedMap, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
out = append(out, ResolvedMap{
|
|
Map: cloneMap(r.table.maps[candidate.mapPos]),
|
|
Source: source,
|
|
SpecificationType: candidate.specificationType,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Resolver) resolveCandidateSet(request ResolveRequest) ([]indexedMap, ResolutionSource, error) {
|
|
if r == nil || r.table == nil {
|
|
return nil, "", ErrResolverTableRequired
|
|
}
|
|
dungeonIndex, ok := r.byDungeon[request.DungeonID]
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, request.DungeonID)
|
|
}
|
|
if request.MazeIndex < 0 || request.MazeIndex >= len(dungeonIndex.mazes) {
|
|
return nil, "", fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, request.DungeonID, request.MazeIndex)
|
|
}
|
|
dungeon := r.table.dungeons[dungeonIndex.dungeonPos]
|
|
maze := dungeon.Mazes[request.MazeIndex]
|
|
index := dungeonIndex.mazes[request.MazeIndex]
|
|
key := coordinateKey{x: request.X, y: request.Y}
|
|
expectedType := expectedMapFileType(maze, key)
|
|
|
|
if len(index.bySpecification[key]) == 0 && !mazeQuestConnected(maze) &&
|
|
(expectedType == MapFileStart || expectedType == MapFileBoss) {
|
|
if candidates := exactFileTypeCandidates(index.byOwnership[key], expectedType); len(candidates) > 0 {
|
|
return candidates, ResolutionDungeonOwnership, nil
|
|
}
|
|
if candidates := exactFileTypeCandidates(index.byCoordinate[key], expectedType); len(candidates) > 0 {
|
|
return candidates, ResolutionDirectoryPath, nil
|
|
}
|
|
}
|
|
if candidates := selectSpecificationCandidates(index.bySpecification[key], maze, key); len(candidates) > 0 {
|
|
return candidates, ResolutionMapSpecification, nil
|
|
}
|
|
if candidates := exactFileTypeCandidates(index.byOwnership[key], expectedType); len(candidates) > 0 {
|
|
return candidates, ResolutionDungeonOwnership, nil
|
|
}
|
|
var exact []indexedMap
|
|
for _, candidate := range index.byCoordinate[key] {
|
|
if candidate.fileType == expectedType {
|
|
exact = appendUniqueIndexedMap(exact, candidate)
|
|
}
|
|
}
|
|
if len(exact) > 0 {
|
|
return exact, ResolutionDirectoryPath, nil
|
|
}
|
|
start, boss := mazeRoomRoles(maze, key)
|
|
for _, fallbackType := range startBossCoordinateFallbackFileTypes(expectedType, start, boss) {
|
|
if candidates := exactFileTypeCandidates(index.byOwnership[key], fallbackType); len(candidates) > 0 {
|
|
return candidates, ResolutionDungeonOwnership, nil
|
|
}
|
|
if candidates := exactFileTypeCandidates(index.byCoordinate[key], fallbackType); len(candidates) > 0 {
|
|
return candidates, ResolutionDirectoryPath, nil
|
|
}
|
|
}
|
|
return nil, "", fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=(%d,%d) expected_type=%s",
|
|
ErrMapUnresolved, request.DungeonID, request.MazeIndex, request.X, request.Y, expectedType,
|
|
)
|
|
}
|
|
|
|
type MapPoolRequest struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
MazeIndex int `json:"maze_index"`
|
|
FileType MapFileType `json:"file_type"`
|
|
}
|
|
|
|
func (r *Resolver) PoolCandidates(request MapPoolRequest) ([]Map, error) {
|
|
index, err := r.mazeIndex(request.DungeonID, request.MazeIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := uniqueIndexedMaps(index.byPool[request.FileType])
|
|
if len(refs) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d file_type=%s",
|
|
ErrMapPoolEmpty, request.DungeonID, request.MazeIndex, request.FileType,
|
|
)
|
|
}
|
|
out := make([]Map, 0, len(refs))
|
|
for _, ref := range refs {
|
|
out = append(out, cloneMap(r.table.maps[ref.mapPos]))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Resolver) ResolvePoolChoice(request MapPoolRequest, mapID int64) (ResolvedMap, error) {
|
|
candidates, err := r.PoolCandidates(request)
|
|
if err != nil {
|
|
return ResolvedMap{}, err
|
|
}
|
|
for _, candidate := range candidates {
|
|
if candidate.ID == mapID {
|
|
return ResolvedMap{Map: candidate, Source: ResolutionTypePool}, nil
|
|
}
|
|
}
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d file_type=%s map_id=%d",
|
|
ErrMapPoolChoiceInvalid, request.DungeonID, request.MazeIndex, request.FileType, mapID,
|
|
)
|
|
}
|
|
|
|
func (r *Resolver) mazeIndex(dungeonID int64, mazeIndex int) (mazeMapIndex, error) {
|
|
if r == nil || r.table == nil {
|
|
return mazeMapIndex{}, ErrResolverTableRequired
|
|
}
|
|
dungeonIndex, ok := r.byDungeon[dungeonID]
|
|
if !ok {
|
|
return mazeMapIndex{}, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
|
|
}
|
|
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
|
|
return mazeMapIndex{}, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
|
|
}
|
|
return dungeonIndex.mazes[mazeIndex], nil
|
|
}
|
|
|
|
func exactFileTypeCandidates(candidates []indexedMap, expectedType MapFileType) []indexedMap {
|
|
var exact []indexedMap
|
|
for _, candidate := range candidates {
|
|
if candidate.fileType == expectedType {
|
|
exact = appendUniqueIndexedMap(exact, candidate)
|
|
}
|
|
}
|
|
return exact
|
|
}
|
|
|
|
func (r *Resolver) TryResolve(request ResolveRequest) (ResolvedMap, bool) {
|
|
resolved, err := r.Resolve(request)
|
|
return resolved, err == nil
|
|
}
|
|
|
|
func (r *Resolver) ResolvePath(dungeonID int64, mazeIndex int, mapPath string) (Map, bool) {
|
|
if r == nil || r.table == nil {
|
|
return Map{}, false
|
|
}
|
|
dungeonIndex, ok := r.byDungeon[dungeonID]
|
|
if !ok || mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
|
|
return Map{}, false
|
|
}
|
|
ref, ok := dungeonIndex.mazes[mazeIndex].byPath[pathKey(mapPath)]
|
|
if !ok {
|
|
return Map{}, false
|
|
}
|
|
return cloneMap(r.table.maps[ref.mapPos]), true
|
|
}
|
|
|
|
func (r *Resolver) Snapshot(dungeonID int64) (DungeonMapIndexSnapshot, bool) {
|
|
if r == nil {
|
|
return DungeonMapIndexSnapshot{}, false
|
|
}
|
|
index, ok := r.byDungeon[dungeonID]
|
|
if !ok {
|
|
return DungeonMapIndexSnapshot{}, false
|
|
}
|
|
snapshot := DungeonMapIndexSnapshot{DungeonID: dungeonID, Mazes: len(index.mazes)}
|
|
for _, maze := range index.mazes {
|
|
snapshot.Specifications += maze.specifications
|
|
for _, entries := range maze.byOwnership {
|
|
snapshot.OwnershipEntries += len(entries)
|
|
}
|
|
for _, entries := range maze.byCoordinate {
|
|
snapshot.CoordinateEntries += len(entries)
|
|
}
|
|
snapshot.PathEntries += len(maze.byPath)
|
|
}
|
|
return snapshot, true
|
|
}
|
|
|
|
func (r *Resolver) resolveCandidates(request ResolveRequest, candidates []indexedMap, source ResolutionSource) (ResolvedMap, error) {
|
|
candidates = uniqueIndexedMaps(candidates)
|
|
if len(candidates) != 1 {
|
|
ids := make([]int64, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
ids = append(ids, r.table.maps[candidate.mapPos].ID)
|
|
}
|
|
return ResolvedMap{}, fmt.Errorf(
|
|
"%w: dungeon=%d maze=%d coordinate=(%d,%d) map_ids=%v",
|
|
ErrMapAmbiguous, request.DungeonID, request.MazeIndex, request.X, request.Y, ids,
|
|
)
|
|
}
|
|
candidate := candidates[0]
|
|
return ResolvedMap{
|
|
Map: cloneMap(r.table.maps[candidate.mapPos]), Source: source,
|
|
SpecificationType: candidate.specificationType,
|
|
}, nil
|
|
}
|
|
|
|
func selectSpecificationCandidates(candidates []indexedMap, maze Maze, key coordinateKey) []indexedMap {
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
expected := expectedMapFileType(maze, key)
|
|
var typed []indexedMap
|
|
for _, candidate := range candidates {
|
|
if candidate.fileType == expected {
|
|
typed = appendUniqueIndexedMap(typed, candidate)
|
|
}
|
|
}
|
|
if len(typed) > 0 {
|
|
return typed
|
|
}
|
|
return uniqueIndexedMaps(candidates)
|
|
}
|
|
|
|
func expectedMapFileType(maze Maze, key coordinateKey) MapFileType {
|
|
if maze.Boss != nil && maze.Boss.X == key.x && maze.Boss.Y == key.y {
|
|
return MapFileBoss
|
|
}
|
|
if maze.Start != nil && maze.Start.X == key.x && maze.Start.Y == key.y {
|
|
return MapFileStart
|
|
}
|
|
if mazeQuestConnected(maze) {
|
|
return MapFileQuest
|
|
}
|
|
return MapFileNormal
|
|
}
|
|
|
|
func mazeRoomRoles(maze Maze, key coordinateKey) (start bool, boss bool) {
|
|
start = maze.Start != nil && maze.Start.X == key.x && maze.Start.Y == key.y
|
|
for _, coordinate := range mazeBossCoordinates(maze.Boss) {
|
|
if coordinate.X == key.x && coordinate.Y == key.y {
|
|
boss = true
|
|
break
|
|
}
|
|
}
|
|
return start, boss
|
|
}
|
|
|
|
func startBossCoordinateFallbackFileTypes(fileType MapFileType, start bool, boss bool) []MapFileType {
|
|
if fileType == MapFileStart && start && !boss {
|
|
// Quest/story maze variants commonly declare [start map] without a
|
|
// matching start-typed MAP or [map specification]. A dungeon-owned MAP
|
|
// at that exact coordinate with a normal filename is the PVF's concrete
|
|
// entrance resource (for example DraconianTower b_1639(0,1)n.map).
|
|
// Exact specifications and exact start types have already won above.
|
|
return []MapFileType{MapFileNormal}
|
|
}
|
|
if fileType != MapFileBoss || !start || !boss {
|
|
return nil
|
|
}
|
|
// Single-room floors mark their only coordinate as both start and boss.
|
|
// The owned MAP may be either an explicitly start-named resource (the
|
|
// training room PoongjinTrainingRoom/11100(0,0)start.map) or a plain
|
|
// normal resource whose filename only embeds the coordinate (the job-change
|
|
// trial 7142 ChangeClass/76032_ChangeClass_S_(0,0).map). Prefer the exact
|
|
// normal owned map first, then fall back to a start-typed map, matching the
|
|
// start==boss pool shape used by dungeonLayoutFallbackPoolFileTypes.
|
|
return []MapFileType{MapFileNormal, MapFileStart}
|
|
}
|
|
|
|
func mazeQuestConnected(maze Maze) bool {
|
|
return len(maze.QuestConnection) >= 2
|
|
}
|
|
|
|
func specificationFileType(value string) MapFileType {
|
|
switch sectionKey(strings.Trim(value, "[]")) {
|
|
case "start":
|
|
return MapFileStart
|
|
case "boss":
|
|
return MapFileBoss
|
|
case "named":
|
|
return MapFileNamed
|
|
case "end":
|
|
return MapFileEnd
|
|
case "hidden":
|
|
return MapFileHidden
|
|
case "quest":
|
|
return MapFileQuest
|
|
case "default":
|
|
return MapFileDefault
|
|
default:
|
|
return MapFileNormal
|
|
}
|
|
}
|
|
|
|
func ParseMapFileCoordinate(mapPath string) (int64, int64, bool) {
|
|
stem := strings.TrimSuffix(path.Base(strings.ReplaceAll(mapPath, "\\", "/")), path.Ext(mapPath))
|
|
match := mapCoordinatePattern.FindStringSubmatch(stem)
|
|
if len(match) != 3 {
|
|
return 0, 0, false
|
|
}
|
|
var x, y int64
|
|
if _, err := fmt.Sscan(match[1], &x); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
if _, err := fmt.Sscan(match[2], &y); err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return x, y, true
|
|
}
|
|
|
|
func ClassifyMapFileType(mapPath string) MapFileType {
|
|
stem := strings.TrimSuffix(path.Base(strings.ReplaceAll(mapPath, "\\", "/")), path.Ext(mapPath))
|
|
lower := strings.ToLower(stem)
|
|
if strings.HasPrefix(lower, "q_") || strings.HasPrefix(lower, "quest") || prefixedDigit(lower, "q") {
|
|
return MapFileQuest
|
|
}
|
|
if strings.HasPrefix(lower, "bn") && len(lower) > 2 && isASCIIDigit(lower[2]) {
|
|
return MapFileNamed
|
|
}
|
|
if strings.Contains(lower, "boss") || prefixedDigit(lower, "b") || typedSuffix(lower, 'b') {
|
|
return MapFileBoss
|
|
}
|
|
if strings.Contains(lower, "start") || prefixedDigit(lower, "s") || typedSuffix(lower, 's') {
|
|
return MapFileStart
|
|
}
|
|
if prefixedDigit(lower, "e") {
|
|
return MapFileEnd
|
|
}
|
|
if prefixedDigit(lower, "h") {
|
|
return MapFileHidden
|
|
}
|
|
if prefixedDigit(lower, "d") {
|
|
return MapFileDefault
|
|
}
|
|
return MapFileNormal
|
|
}
|
|
|
|
func prefixedDigit(value, prefix string) bool {
|
|
return strings.HasPrefix(value, prefix) && len(value) > len(prefix) && isASCIIDigit(value[len(prefix)])
|
|
}
|
|
|
|
func typedSuffix(value string, suffix byte) bool {
|
|
if len(value) < 2 || value[len(value)-1] != suffix {
|
|
return false
|
|
}
|
|
previous := value[len(value)-2]
|
|
return isASCIIDigit(previous) || previous == ')'
|
|
}
|
|
|
|
func isASCIIDigit(value byte) bool {
|
|
return value >= '0' && value <= '9'
|
|
}
|
|
|
|
func appendUniqueIndexedMap(target []indexedMap, candidate indexedMap) []indexedMap {
|
|
for _, existing := range target {
|
|
if existing.mapPos == candidate.mapPos && existing.fileType == candidate.fileType {
|
|
return target
|
|
}
|
|
}
|
|
return append(target, candidate)
|
|
}
|
|
|
|
func uniqueIndexedMaps(values []indexedMap) []indexedMap {
|
|
var out []indexedMap
|
|
seen := make(map[int]struct{}, len(values))
|
|
for _, value := range values {
|
|
if _, ok := seen[value.mapPos]; ok {
|
|
continue
|
|
}
|
|
seen[value.mapPos] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|