DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user