服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
533 lines
16 KiB
Go
533 lines
16 KiB
Go
package worldmap
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
var (
|
|
ErrTopologyResolverRequired = errors.New("dnf dungeon topology resolver is required")
|
|
ErrTopologyEmpty = errors.New("dnf dungeon topology has no rooms")
|
|
)
|
|
|
|
type RoomDirection string
|
|
|
|
const (
|
|
RoomDirectionUp RoomDirection = "up"
|
|
RoomDirectionRight RoomDirection = "right"
|
|
RoomDirectionDown RoomDirection = "down"
|
|
RoomDirectionLeft RoomDirection = "left"
|
|
)
|
|
|
|
type RoomCoordinate struct {
|
|
X int64 `json:"x"`
|
|
Y int64 `json:"y"`
|
|
}
|
|
|
|
func (c RoomCoordinate) String() string {
|
|
return fmt.Sprintf("%d:%d", c.X, c.Y)
|
|
}
|
|
|
|
type RoomNeighbor struct {
|
|
Direction RoomDirection `json:"direction"`
|
|
Coordinate RoomCoordinate `json:"coordinate"`
|
|
}
|
|
|
|
type DungeonRoom struct {
|
|
Coordinate RoomCoordinate `json:"coordinate"`
|
|
Start bool `json:"start,omitempty"`
|
|
Boss bool `json:"boss,omitempty"`
|
|
Map *ResolvedMap `json:"map,omitempty"`
|
|
ResolutionError string `json:"resolution_error,omitempty"`
|
|
Neighbors []RoomNeighbor `json:"neighbors,omitempty"`
|
|
}
|
|
|
|
type TopologyDiagnostic struct {
|
|
Code string `json:"code"`
|
|
Coordinate *RoomCoordinate `json:"coordinate,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type DungeonTopology struct {
|
|
DungeonID int64 `json:"dungeon_id"`
|
|
MazeIndex int `json:"maze_index"`
|
|
Width OptionalInt `json:"width"`
|
|
Height OptionalInt `json:"height"`
|
|
Start *RoomCoordinate `json:"start,omitempty"`
|
|
Bosses []RoomCoordinate `json:"bosses,omitempty"`
|
|
AllowMoveBeforeClear bool `json:"allow_move_before_clear,omitempty"`
|
|
Diagnostics []TopologyDiagnostic `json:"diagnostics,omitempty"`
|
|
|
|
rooms map[coordinateKey]DungeonRoom
|
|
resolutionErrors map[coordinateKey]error
|
|
warpTransitions map[topologyTransition]struct{}
|
|
resolver *Resolver
|
|
}
|
|
|
|
type topologyTransition struct {
|
|
from coordinateKey
|
|
to coordinateKey
|
|
}
|
|
|
|
type topologyDelta struct {
|
|
direction RoomDirection
|
|
dx int64
|
|
dy int64
|
|
mask uint8
|
|
opposite uint8
|
|
}
|
|
|
|
var topologyDeltas = [...]topologyDelta{
|
|
{direction: RoomDirectionUp, dx: 0, dy: -1, mask: 0x02, opposite: 0x08},
|
|
{direction: RoomDirectionRight, dx: 1, dy: 0, mask: 0x01, opposite: 0x04},
|
|
{direction: RoomDirectionDown, dx: 0, dy: 1, mask: 0x08, opposite: 0x02},
|
|
{direction: RoomDirectionLeft, dx: -1, dy: 0, mask: 0x04, opposite: 0x01},
|
|
}
|
|
|
|
func BuildDungeonTopology(resolver *Resolver, dungeonID int64, mazeIndex int) (*DungeonTopology, error) {
|
|
if resolver == nil || resolver.table == nil {
|
|
return nil, ErrTopologyResolverRequired
|
|
}
|
|
dungeonIndex, ok := resolver.byDungeon[dungeonID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: dungeon=%d", ErrDungeonNotIndexed, dungeonID)
|
|
}
|
|
if mazeIndex < 0 || mazeIndex >= len(dungeonIndex.mazes) {
|
|
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrMazeNotFound, dungeonID, mazeIndex)
|
|
}
|
|
|
|
dungeon := resolver.table.dungeons[dungeonIndex.dungeonPos]
|
|
maze := dungeon.Mazes[mazeIndex]
|
|
topology := &DungeonTopology{
|
|
DungeonID: dungeonID, MazeIndex: mazeIndex,
|
|
Width: maze.Width, Height: maze.Height,
|
|
AllowMoveBeforeClear: dungeon.Metadata.Flags["move map even enemy"],
|
|
rooms: make(map[coordinateKey]DungeonRoom),
|
|
resolutionErrors: make(map[coordinateKey]error),
|
|
warpTransitions: make(map[topologyTransition]struct{}),
|
|
resolver: resolver,
|
|
}
|
|
cells := make(map[coordinateKey]struct{})
|
|
greedMasks, structuredGreed := addGreedCells(topology, maze, cells)
|
|
addSpecificationCells(cells, maze.MapSpecifications)
|
|
addSpecificationCells(cells, maze.BossSpecifications)
|
|
addSpecificationCells(cells, maze.LayeredSpecifications)
|
|
|
|
if maze.Start != nil {
|
|
start := RoomCoordinate{X: maze.Start.X, Y: maze.Start.Y}
|
|
topology.Start = &start
|
|
addExplicitCell(topology, maze, cells, start, "start_out_of_bounds")
|
|
}
|
|
for _, boss := range mazeBossCoordinates(maze.Boss) {
|
|
topology.Bosses = append(topology.Bosses, boss)
|
|
addExplicitCell(topology, maze, cells, boss, "boss_out_of_bounds")
|
|
}
|
|
warpConditions := maze.WarpMapConditions
|
|
if len(warpConditions) == 0 {
|
|
// Preserve programmatic/legacy tables that predate per-maze ownership.
|
|
warpConditions = dungeon.WarpMapConditions
|
|
}
|
|
// A boss-entrance warp does not authorize travel to its destination
|
|
// coordinate. Its source remains the logical room while the destination only
|
|
// supplies a replacement map template after the condition is complete.
|
|
if maze.BossRoomEntranceCondition == nil {
|
|
addWarpTransitions(topology, maze, cells, warpConditions)
|
|
}
|
|
|
|
if !structuredGreed {
|
|
// The resolver already limits these paths to directories owned by this maze's
|
|
// explicit specifications. Coordinates are data, not a map-selection fallback.
|
|
for key := range dungeonIndex.mazes[mazeIndex].byCoordinate {
|
|
coordinate := RoomCoordinate{X: key.x, Y: key.y}
|
|
if withinMazeBounds(maze, coordinate) {
|
|
cells[key] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if len(cells) == 0 {
|
|
return nil, fmt.Errorf("%w: dungeon=%d maze=%d", ErrTopologyEmpty, dungeonID, mazeIndex)
|
|
}
|
|
|
|
bosses := make(map[coordinateKey]struct{}, len(topology.Bosses))
|
|
for _, boss := range topology.Bosses {
|
|
bosses[coordinateKey{x: boss.X, y: boss.Y}] = struct{}{}
|
|
}
|
|
for key := range cells {
|
|
coordinate := RoomCoordinate{X: key.x, Y: key.y}
|
|
room := DungeonRoom{Coordinate: coordinate}
|
|
room.Start = topology.Start != nil && topology.Start.X == key.x && topology.Start.Y == key.y
|
|
_, room.Boss = bosses[key]
|
|
resolved, err := resolver.Resolve(ResolveRequest{
|
|
DungeonID: dungeonID, MazeIndex: mazeIndex, X: key.x, Y: key.y,
|
|
})
|
|
if err != nil {
|
|
room.ResolutionError = err.Error()
|
|
topology.resolutionErrors[key] = err
|
|
topology.addDiagnostic("room_map_unresolved", &coordinate, err.Error())
|
|
} else {
|
|
room.Map = &resolved
|
|
}
|
|
topology.rooms[key] = room
|
|
}
|
|
for key, room := range topology.rooms {
|
|
for _, delta := range topologyDeltas {
|
|
neighborKey := coordinateKey{x: key.x + delta.dx, y: key.y + delta.dy}
|
|
if _, ok := topology.rooms[neighborKey]; !ok {
|
|
continue
|
|
}
|
|
if structuredGreed {
|
|
currentMask, currentInGrid := greedMasks[key]
|
|
neighborMask, neighborInGrid := greedMasks[neighborKey]
|
|
if !currentInGrid || !neighborInGrid || currentMask&delta.mask == 0 || neighborMask&delta.opposite == 0 {
|
|
continue
|
|
}
|
|
}
|
|
room.Neighbors = append(room.Neighbors, RoomNeighbor{
|
|
Direction: delta.direction,
|
|
Coordinate: RoomCoordinate{X: neighborKey.x, Y: neighborKey.y},
|
|
})
|
|
}
|
|
topology.rooms[key] = room
|
|
}
|
|
return topology, nil
|
|
}
|
|
|
|
func (t *DungeonTopology) allowsWarpTransition(from, to coordinateKey) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
_, ok := t.warpTransitions[topologyTransition{from: from, to: to}]
|
|
return ok
|
|
}
|
|
|
|
func (t *DungeonTopology) Room(coordinate RoomCoordinate) (DungeonRoom, bool) {
|
|
if t == nil {
|
|
return DungeonRoom{}, false
|
|
}
|
|
room, ok := t.rooms[coordinateKey{x: coordinate.X, y: coordinate.Y}]
|
|
if !ok {
|
|
return DungeonRoom{}, false
|
|
}
|
|
return cloneDungeonRoom(room), true
|
|
}
|
|
|
|
// ReplaceRoomMap updates the resolved map for an existing coordinate while
|
|
// preserving topology identity and adjacency.
|
|
func (t *DungeonTopology) ReplaceRoomMap(coordinate RoomCoordinate, resolved ResolvedMap) error {
|
|
if t == nil {
|
|
return ErrTopologyEmpty
|
|
}
|
|
key := coordinateKey{x: coordinate.X, y: coordinate.Y}
|
|
room, ok := t.rooms[key]
|
|
if !ok {
|
|
return fmt.Errorf("%w: room=%s", ErrMapUnresolved, coordinate)
|
|
}
|
|
if resolved.Map.ID <= 0 {
|
|
return fmt.Errorf("%w: room=%s map=%d", ErrMapReferenceMissing, coordinate, resolved.Map.ID)
|
|
}
|
|
copy := resolved
|
|
copy.Map = cloneMap(resolved.Map)
|
|
room.Map = ©
|
|
room.ResolutionError = ""
|
|
t.rooms[key] = room
|
|
delete(t.resolutionErrors, key)
|
|
filtered := t.Diagnostics[:0]
|
|
for _, diagnostic := range t.Diagnostics {
|
|
if diagnostic.Code == "room_map_unresolved" && diagnostic.Coordinate != nil && *diagnostic.Coordinate == coordinate {
|
|
continue
|
|
}
|
|
filtered = append(filtered, diagnostic)
|
|
}
|
|
t.Diagnostics = filtered
|
|
return nil
|
|
}
|
|
|
|
func (t *DungeonTopology) Rooms() []DungeonRoom {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
rooms := make([]DungeonRoom, 0, len(t.rooms))
|
|
for _, room := range t.rooms {
|
|
rooms = append(rooms, cloneDungeonRoom(room))
|
|
}
|
|
sort.Slice(rooms, func(i, j int) bool {
|
|
if rooms[i].Coordinate.Y != rooms[j].Coordinate.Y {
|
|
return rooms[i].Coordinate.Y < rooms[j].Coordinate.Y
|
|
}
|
|
return rooms[i].Coordinate.X < rooms[j].Coordinate.X
|
|
})
|
|
return rooms
|
|
}
|
|
|
|
func (t *DungeonTopology) prepareRoom(room DungeonRoom) DungeonRoom {
|
|
return cloneDungeonRoom(room)
|
|
}
|
|
|
|
func (t *DungeonTopology) UnresolvedRooms() []DungeonRoom {
|
|
rooms := t.Rooms()
|
|
unresolved := rooms[:0]
|
|
for _, room := range rooms {
|
|
if room.Map == nil {
|
|
unresolved = append(unresolved, room)
|
|
}
|
|
}
|
|
return unresolved
|
|
}
|
|
|
|
func (t *DungeonTopology) addDiagnostic(code string, coordinate *RoomCoordinate, message string) {
|
|
var copied *RoomCoordinate
|
|
if coordinate != nil {
|
|
value := *coordinate
|
|
copied = &value
|
|
}
|
|
t.Diagnostics = append(t.Diagnostics, TopologyDiagnostic{Code: code, Coordinate: copied, Message: message})
|
|
}
|
|
|
|
func addGreedCells(
|
|
topology *DungeonTopology,
|
|
maze Maze,
|
|
cells map[coordinateKey]struct{},
|
|
) (map[coordinateKey]uint8, bool) {
|
|
if maze.Greed == "" {
|
|
return nil, false
|
|
}
|
|
if !maze.Width.Set || !maze.Height.Set || maze.Width.Value <= 0 || maze.Height.Value <= 0 {
|
|
topology.addDiagnostic("maze_size_invalid", nil, "[greed] requires positive [size] width and height")
|
|
return nil, false
|
|
}
|
|
rows := normalizedGreedRows(maze.Greed)
|
|
if len(rows) == 0 {
|
|
return nil, false
|
|
}
|
|
paired := greedRowsArePaired(rows, maze.Width.Value)
|
|
if int64(len(rows)) < maze.Height.Value {
|
|
topology.addDiagnostic(
|
|
"maze_grid_rows_short", nil,
|
|
fmt.Sprintf("[greed] has %d rows, [size] declares %d; omitted rows are empty", len(rows), maze.Height.Value),
|
|
)
|
|
} else if int64(len(rows)) > maze.Height.Value {
|
|
topology.addDiagnostic(
|
|
"maze_grid_rows_extra", nil,
|
|
fmt.Sprintf("[greed] has %d rows, [size] declares %d", len(rows), maze.Height.Value),
|
|
)
|
|
}
|
|
masks := make(map[coordinateKey]uint8)
|
|
rowCount := int64(len(rows))
|
|
if rowCount > maze.Height.Value {
|
|
rowCount = maze.Height.Value
|
|
}
|
|
for y := int64(0); y < rowCount; y++ {
|
|
row := rows[y]
|
|
unitWidth := int64(1)
|
|
if paired {
|
|
unitWidth = 2
|
|
if len(row)%2 != 0 {
|
|
topology.addDiagnostic(
|
|
"maze_grid_row_odd", &RoomCoordinate{X: int64(len(row) / 2), Y: y},
|
|
fmt.Sprintf("[greed] row %d has an unmatched paired symbol", y),
|
|
)
|
|
}
|
|
}
|
|
logicalWidth := int64(len(row)) / unitWidth
|
|
if logicalWidth < maze.Width.Value {
|
|
topology.addDiagnostic(
|
|
"maze_grid_row_short", &RoomCoordinate{X: logicalWidth, Y: y},
|
|
fmt.Sprintf("[greed] row %d has %d cells, [size] declares %d; omitted cells are empty", y, logicalWidth, maze.Width.Value),
|
|
)
|
|
} else if logicalWidth > maze.Width.Value {
|
|
topology.addDiagnostic(
|
|
"maze_grid_row_extra", &RoomCoordinate{X: maze.Width.Value, Y: y},
|
|
fmt.Sprintf("[greed] row %d has %d cells, [size] declares %d", y, logicalWidth, maze.Width.Value),
|
|
)
|
|
logicalWidth = maze.Width.Value
|
|
}
|
|
for x := int64(0); x < logicalWidth; x++ {
|
|
valueIndex := x * unitWidth
|
|
if paired {
|
|
if valueIndex+1 >= int64(len(row)) {
|
|
break
|
|
}
|
|
if row[valueIndex] != row[valueIndex+1] {
|
|
coordinate := RoomCoordinate{X: x, Y: y}
|
|
topology.addDiagnostic(
|
|
"maze_grid_pair_mismatch", &coordinate,
|
|
fmt.Sprintf("[greed] pair %q%q does not repeat one room code", row[valueIndex], row[valueIndex+1]),
|
|
)
|
|
continue
|
|
}
|
|
}
|
|
mask, ok := greedRoomMask(row[valueIndex])
|
|
if !ok {
|
|
coordinate := RoomCoordinate{X: x, Y: y}
|
|
topology.addDiagnostic(
|
|
"maze_grid_code_unknown", &coordinate,
|
|
fmt.Sprintf("[greed] room code %q is unknown", row[valueIndex]),
|
|
)
|
|
continue
|
|
}
|
|
if mask != 0 {
|
|
key := coordinateKey{x: x, y: y}
|
|
cells[key] = struct{}{}
|
|
masks[key] = mask
|
|
}
|
|
}
|
|
}
|
|
return masks, true
|
|
}
|
|
|
|
func normalizedGreedRows(value string) [][]rune {
|
|
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
|
rows := make([][]rune, 0, len(lines))
|
|
for _, line := range lines {
|
|
row := make([]rune, 0, len(line))
|
|
for _, symbol := range line {
|
|
if unicode.IsSpace(symbol) || symbol == '`' || symbol == ',' {
|
|
continue
|
|
}
|
|
row = append(row, symbol)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
for len(rows) > 0 && len(rows[0]) == 0 {
|
|
rows = rows[1:]
|
|
}
|
|
for len(rows) > 0 && len(rows[len(rows)-1]) == 0 {
|
|
rows = rows[:len(rows)-1]
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func greedRowsArePaired(rows [][]rune, width int64) bool {
|
|
allRepeated := true
|
|
hasSymbols := false
|
|
for _, row := range rows {
|
|
if int64(len(row)) > width {
|
|
return true
|
|
}
|
|
if len(row) == 0 {
|
|
continue
|
|
}
|
|
hasSymbols = true
|
|
if len(row)%2 != 0 {
|
|
allRepeated = false
|
|
continue
|
|
}
|
|
for index := 0; index < len(row); index += 2 {
|
|
if row[index] != row[index+1] {
|
|
allRepeated = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return hasSymbols && allRepeated
|
|
}
|
|
|
|
func greedRoomMask(value rune) (uint8, bool) {
|
|
if value >= 'a' && value <= 'p' {
|
|
value -= 'a' - 'A'
|
|
}
|
|
if value >= 'A' && value <= 'P' {
|
|
return uint8(value - 'A'), true
|
|
}
|
|
switch value {
|
|
case '0', '.', 'x', 'X':
|
|
return 0, true
|
|
case '1':
|
|
return 0x0f, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func addSpecificationCells(cells map[coordinateKey]struct{}, specifications []MapSpecification) {
|
|
for _, specification := range specifications {
|
|
cells[coordinateKey{x: specification.Coordinate.X, y: specification.Coordinate.Y}] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func addWarpTransitions(
|
|
topology *DungeonTopology,
|
|
maze Maze,
|
|
cells map[coordinateKey]struct{},
|
|
conditions []WarpMapCondition,
|
|
) {
|
|
for _, condition := range conditions {
|
|
source := RoomCoordinate{X: condition.Source.X, Y: condition.Source.Y}
|
|
destination := RoomCoordinate{X: condition.Destination.X, Y: condition.Destination.Y}
|
|
// Negative source coordinates are PVF mechanism sentinels. They are not
|
|
// client-requestable maze rooms and must never become travel origins.
|
|
if source.X < 0 || source.Y < 0 {
|
|
continue
|
|
}
|
|
if !withinMazeBounds(maze, source) {
|
|
topology.addDiagnostic(
|
|
"warp_source_out_of_bounds", &source,
|
|
fmt.Sprintf("warp source %s is outside declared maze size", source),
|
|
)
|
|
continue
|
|
}
|
|
if destination.X < 0 || destination.Y < 0 || !withinMazeBounds(maze, destination) {
|
|
topology.addDiagnostic(
|
|
"warp_destination_out_of_bounds", &destination,
|
|
fmt.Sprintf("warp destination %s is outside declared maze size", destination),
|
|
)
|
|
continue
|
|
}
|
|
sourceKey := coordinateKey{x: source.X, y: source.Y}
|
|
destinationKey := coordinateKey{x: destination.X, y: destination.Y}
|
|
cells[sourceKey] = struct{}{}
|
|
cells[destinationKey] = struct{}{}
|
|
topology.warpTransitions[topologyTransition{from: sourceKey, to: destinationKey}] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func addExplicitCell(
|
|
topology *DungeonTopology,
|
|
maze Maze,
|
|
cells map[coordinateKey]struct{},
|
|
coordinate RoomCoordinate,
|
|
diagnosticCode string,
|
|
) {
|
|
if !withinMazeBounds(maze, coordinate) {
|
|
topology.addDiagnostic(
|
|
diagnosticCode, &coordinate,
|
|
fmt.Sprintf("coordinate %s is outside declared maze size", coordinate),
|
|
)
|
|
}
|
|
cells[coordinateKey{x: coordinate.X, y: coordinate.Y}] = struct{}{}
|
|
}
|
|
|
|
func withinMazeBounds(maze Maze, coordinate RoomCoordinate) bool {
|
|
if maze.Width.Set && maze.Width.Value > 0 && (coordinate.X < 0 || coordinate.X >= maze.Width.Value) {
|
|
return false
|
|
}
|
|
if maze.Height.Set && maze.Height.Value > 0 && (coordinate.Y < 0 || coordinate.Y >= maze.Height.Value) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func mazeBossCoordinates(point *MazePoint) []RoomCoordinate {
|
|
if point == nil {
|
|
return nil
|
|
}
|
|
coordinates := []RoomCoordinate{{X: point.X, Y: point.Y}}
|
|
for index := 0; index+1 < len(point.Params); index += 2 {
|
|
coordinates = append(coordinates, RoomCoordinate{X: point.Params[index], Y: point.Params[index+1]})
|
|
}
|
|
return coordinates
|
|
}
|
|
|
|
func cloneDungeonRoom(room DungeonRoom) DungeonRoom {
|
|
room.Neighbors = append([]RoomNeighbor(nil), room.Neighbors...)
|
|
if room.Map != nil {
|
|
resolved := *room.Map
|
|
resolved.Map = cloneMap(resolved.Map)
|
|
room.Map = &resolved
|
|
}
|
|
return room
|
|
}
|