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

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

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

No files matched your search

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