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,980 @@
|
||||
package worldmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDungeonRunRequired = errors.New("dnf dungeon run is required")
|
||||
ErrHostileReferenceInvalid = errors.New("dnf dungeon hostile reference is invalid")
|
||||
ErrRuntimeObjectKeyInvalid = errors.New("dnf dungeon runtime object key is invalid")
|
||||
ErrRuntimeObjectKeyBound = errors.New("dnf dungeon runtime object key is already bound")
|
||||
ErrHostileAlreadyBound = errors.New("dnf dungeon hostile is already bound")
|
||||
ErrHostileNotBound = errors.New("dnf dungeon hostile runtime object is not bound")
|
||||
ErrHostileAlreadyDefeated = errors.New("dnf dungeon hostile is already defeated")
|
||||
ErrRevisitSceneRequired = errors.New("dnf dungeon revisit scene is required")
|
||||
ErrRevisitSceneUnexpected = errors.New("dnf dungeon revisit scene was supplied for a first visit")
|
||||
ErrRevisitSceneMismatch = errors.New("dnf dungeon revisit scene does not match persistent room state")
|
||||
)
|
||||
|
||||
type HostileKind string
|
||||
|
||||
const (
|
||||
HostileMonster HostileKind = "monster"
|
||||
HostileAICharacter HostileKind = "ai_character"
|
||||
)
|
||||
|
||||
type HostileReference struct {
|
||||
Kind HostileKind `json:"kind"`
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
type DungeonRoomScene struct {
|
||||
Coordinate RoomCoordinate `json:"coordinate"`
|
||||
Start bool `json:"start,omitempty"`
|
||||
Boss bool `json:"boss,omitempty"`
|
||||
Map ResolvedMap `json:"map"`
|
||||
Monsters []MonsterSpawn `json:"monsters,omitempty"`
|
||||
NPCs []NPCSpawn `json:"npcs,omitempty"`
|
||||
AICharacters []AICharacter `json:"ai_characters,omitempty"`
|
||||
PassiveObjects []PassiveObject `json:"passive_objects,omitempty"`
|
||||
SpecialPassiveObjects []SpecialPassiveObject `json:"special_passive_objects,omitempty"`
|
||||
Portals []Portal `json:"portals,omitempty"`
|
||||
Summons []MapSummon `json:"summons,omitempty"`
|
||||
ExpectedHostiles []HostileReference `json:"expected_hostiles,omitempty"`
|
||||
BlockingHostiles []HostileReference `json:"blocking_hostiles,omitempty"`
|
||||
RuntimeObjects map[uint32]HostileReference `json:"runtime_objects,omitempty"`
|
||||
DefeatedObjects []uint32 `json:"defeated_objects,omitempty"`
|
||||
Cleared bool `json:"cleared,omitempty"`
|
||||
}
|
||||
|
||||
type DungeonSessionSnapshot struct {
|
||||
Run DungeonRunSnapshot `json:"run"`
|
||||
Scene DungeonRoomScene `json:"scene"`
|
||||
}
|
||||
|
||||
// DungeonRoomTransition describes a validated room target. Revisit is derived
|
||||
// only from DungeonRun's persistent visited set; callers must not infer it from
|
||||
// a packet cache or from the target room's PVF contents.
|
||||
type DungeonRoomTransition struct {
|
||||
Scene DungeonRoomScene `json:"scene"`
|
||||
Revisit bool `json:"revisit,omitempty"`
|
||||
}
|
||||
|
||||
type validatedDungeonRoomTransition struct {
|
||||
transition DungeonRoomTransition
|
||||
bindings map[HostileReference]uint32
|
||||
defeated map[uint32]struct{}
|
||||
}
|
||||
|
||||
type DungeonSession struct {
|
||||
mu sync.RWMutex
|
||||
run *DungeonRun
|
||||
scene DungeonRoomScene
|
||||
bindings map[HostileReference]uint32
|
||||
defeated map[uint32]struct{}
|
||||
}
|
||||
|
||||
func NewDungeonSession(run *DungeonRun) (*DungeonSession, error) {
|
||||
if run == nil {
|
||||
return nil, ErrDungeonRunRequired
|
||||
}
|
||||
room, ok := run.CurrentRoom()
|
||||
if !ok {
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
session := &DungeonSession{run: run}
|
||||
if err := session.loadRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) Scene() (DungeonRoomScene, bool) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomScene{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return cloneDungeonRoomScene(s.scene), true
|
||||
}
|
||||
|
||||
// Rooms exposes the run's frozen map choices without exposing its mutable
|
||||
// visit/clear state. Party orchestration uses this to give every member the
|
||||
// leader's exact PVF map selection.
|
||||
func (s *DungeonSession) Rooms() []DungeonRoom {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
run := s.run
|
||||
s.mu.RUnlock()
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
return run.Rooms()
|
||||
}
|
||||
|
||||
func (s *DungeonSession) BindHostileObject(reference HostileReference, objectKey uint32) error {
|
||||
if s == nil || s.run == nil {
|
||||
return ErrDungeonRunRequired
|
||||
}
|
||||
if objectKey == 0 {
|
||||
return ErrRuntimeObjectKeyInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !containsHostileReference(s.scene.ExpectedHostiles, reference) {
|
||||
return fmt.Errorf("%w: kind=%s index=%d", ErrHostileReferenceInvalid, reference.Kind, reference.Index)
|
||||
}
|
||||
if previous, ok := s.bindings[reference]; ok {
|
||||
return fmt.Errorf("%w: kind=%s index=%d object_key=%d", ErrHostileAlreadyBound, reference.Kind, reference.Index, previous)
|
||||
}
|
||||
if previous, ok := s.scene.RuntimeObjects[objectKey]; ok {
|
||||
return fmt.Errorf("%w: object_key=%d kind=%s index=%d", ErrRuntimeObjectKeyBound, objectKey, previous.Kind, previous.Index)
|
||||
}
|
||||
s.bindings[reference] = objectKey
|
||||
s.scene.RuntimeObjects[objectKey] = reference
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkHostileNonBlocking keeps a scripted actor in the room's runtime object
|
||||
// table while excluding it from the ordinary all-monsters-dead door scan.
|
||||
func (s *DungeonSession) MarkHostileNonBlocking(reference HostileReference) (bool, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return false, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !containsHostileReference(s.scene.ExpectedHostiles, reference) {
|
||||
return false, fmt.Errorf("%w: kind=%s index=%d", ErrHostileReferenceInvalid, reference.Kind, reference.Index)
|
||||
}
|
||||
removed := false
|
||||
blocking := s.scene.BlockingHostiles[:0]
|
||||
for _, candidate := range s.scene.BlockingHostiles {
|
||||
if candidate == reference {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
blocking = append(blocking, candidate)
|
||||
}
|
||||
if !removed {
|
||||
return false, nil
|
||||
}
|
||||
s.scene.BlockingHostiles = blocking
|
||||
if len(s.scene.BlockingHostiles) == 0 && !s.scene.Cleared {
|
||||
if err := s.run.MarkCurrentRoomCleared(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.scene.Cleared = true
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) MarkHostileDefeated(objectKey uint32) (bool, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return false, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.scene.RuntimeObjects[objectKey]; !ok {
|
||||
return false, fmt.Errorf("%w: object_key=%d", ErrHostileNotBound, objectKey)
|
||||
}
|
||||
if _, ok := s.defeated[objectKey]; ok {
|
||||
return false, fmt.Errorf("%w: object_key=%d", ErrHostileAlreadyDefeated, objectKey)
|
||||
}
|
||||
s.defeated[objectKey] = struct{}{}
|
||||
s.scene.DefeatedObjects = append(s.scene.DefeatedObjects, objectKey)
|
||||
if s.scene.Cleared {
|
||||
return false, nil
|
||||
}
|
||||
for _, reference := range s.scene.BlockingHostiles {
|
||||
boundObjectKey, ok := s.bindings[reference]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if _, defeated := s.defeated[boundObjectKey]; !defeated {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := s.run.MarkCurrentRoomCleared(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.scene.Cleared = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PreviewMoveByte resolves and validates the target room without changing the
|
||||
// active scene or any DungeonRun state. Packet owners must use this before
|
||||
// writing a room-transition response.
|
||||
func (s *DungeonSession) PreviewMoveByte(nextX, nextY byte) (DungeonRoomScene, error) {
|
||||
transition, err := s.PreviewMoveByteTransition(nextX, nextY)
|
||||
return transition.Scene, err
|
||||
}
|
||||
|
||||
// PreviewLayered validates a same-coordinate PVF layer without mutating the
|
||||
// run, scene, runtime bindings, defeated set, or current-room clear state.
|
||||
// Source-room clearance is intentionally not required: the client can request
|
||||
// a cinematic layer while scripted source actors remain alive.
|
||||
func (s *DungeonSession) PreviewLayered(
|
||||
coordinate RoomCoordinate,
|
||||
layerIndex int,
|
||||
) (DungeonRoomScene, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomScene{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.previewLayeredLocked(coordinate, layerIndex)
|
||||
}
|
||||
|
||||
// PreviewStoryStage validates the next ordered activity-story map without
|
||||
// mutating the run. Revisit reports whether the target coordinate was already
|
||||
// rendered; the returned scene is always freshly built from the stage map.
|
||||
func (s *DungeonSession) PreviewStoryStage(stageIndex int) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
room, revisit, err := s.run.previewStoryStage(stageIndex)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, nil
|
||||
}
|
||||
|
||||
// CommitStoryStage installs a fresh ordered story scene. Even when its
|
||||
// coordinate was visited earlier, cached base/stage actors are never restored;
|
||||
// payload mode 2 makes the client refresh that coordinate with this new map.
|
||||
func (s *DungeonSession) CommitStoryStage(stageIndex int) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
room, revisit, err := s.run.previewStoryStage(stageIndex)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
if _, err := s.run.commitStoryStage(
|
||||
stageIndex,
|
||||
scene.Coordinate,
|
||||
scene.Map.Map.ID,
|
||||
revisit,
|
||||
scene.Cleared,
|
||||
); err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
s.installSceneLocked(scene)
|
||||
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: revisit}, nil
|
||||
}
|
||||
|
||||
// CommitLayered installs a fresh scene at the current coordinate and resets
|
||||
// all object bindings and defeats from the previous layer. DungeonRun's clear
|
||||
// bit is also reset (or recomputed for an intrinsically empty target layer),
|
||||
// so an already-cleared source cannot make the target immediately passable.
|
||||
func (s *DungeonSession) CommitLayered(
|
||||
coordinate RoomCoordinate,
|
||||
layerIndex int,
|
||||
) (DungeonRoomScene, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomScene{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
scene, err := s.previewLayeredLocked(coordinate, layerIndex)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
if _, err := s.run.commitCurrentLayered(
|
||||
coordinate,
|
||||
layerIndex,
|
||||
scene.Map.Map.ID,
|
||||
scene.Cleared,
|
||||
); err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
s.installSceneLocked(scene)
|
||||
return cloneDungeonRoomScene(s.scene), nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) previewLayeredLocked(
|
||||
coordinate RoomCoordinate,
|
||||
layerIndex int,
|
||||
) (DungeonRoomScene, error) {
|
||||
room, err := s.run.previewCurrentLayered(coordinate, layerIndex)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
// ValidateLayeredBase validates a caller-owned cached base scene for the
|
||||
// current coordinate after the explicit PVF layer list is exhausted. The
|
||||
// cached scene must match the topology-owned base map; a stale or foreign
|
||||
// cache fails closed. The run, active scene, runtime bindings, and defeated
|
||||
// set are not mutated.
|
||||
func (s *DungeonSession) ValidateLayeredBase(
|
||||
coordinate RoomCoordinate,
|
||||
cachedScene DungeonRoomScene,
|
||||
) (DungeonRoomScene, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomScene{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
scene, _, _, err := s.validateLayeredBaseLocked(coordinate, cachedScene)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
return cloneDungeonRoomScene(scene), nil
|
||||
}
|
||||
|
||||
// CommitLayeredBase restores a caller-owned cached base scene after the
|
||||
// explicit PVF layer list is exhausted. Like the revisit restore path, the
|
||||
// cached runtime bindings and defeats are reinstalled so the base room's
|
||||
// prior combat state survives the layered detour; the coordinate's clear bit
|
||||
// is replaced with the validated base room state.
|
||||
func (s *DungeonSession) CommitLayeredBase(
|
||||
coordinate RoomCoordinate,
|
||||
cachedScene DungeonRoomScene,
|
||||
) (DungeonRoomScene, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomScene{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
scene, bindings, defeated, err := s.validateLayeredBaseLocked(coordinate, cachedScene)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
if _, err := s.run.commitCurrentLayeredBase(coordinate, scene.Map.Map.ID, scene.Cleared); err != nil {
|
||||
return DungeonRoomScene{}, err
|
||||
}
|
||||
s.installRestoredSceneLocked(scene, bindings, defeated)
|
||||
return cloneDungeonRoomScene(s.scene), nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) validateLayeredBaseLocked(
|
||||
coordinate RoomCoordinate,
|
||||
cachedScene DungeonRoomScene,
|
||||
) (DungeonRoomScene, map[HostileReference]uint32, map[uint32]struct{}, error) {
|
||||
baseRoom, _, err := s.run.previewCurrentLayeredBase(coordinate)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, nil, nil, err
|
||||
}
|
||||
expected, err := dungeonRoomScene(baseRoom)
|
||||
if err != nil {
|
||||
return DungeonRoomScene{}, nil, nil, err
|
||||
}
|
||||
// A layered commit replaced the coordinate's clear bit with the layer's
|
||||
// state, so the cached base scene is the only surviving record of the base
|
||||
// room's clear state. The revisit validator's strict structural checks
|
||||
// still apply; its persistent clear-state input is the cached scene itself.
|
||||
return validateRevisitDungeonRoomScene(expected, cachedScene, cachedScene.Cleared)
|
||||
}
|
||||
|
||||
// PreviewMoveByteTransition additionally reports whether the target was
|
||||
// already visited. Its Scene is the current PVF definition used to validate a
|
||||
// caller-owned cached scene; it intentionally contains no reconstructed prior
|
||||
// runtime objects.
|
||||
func (s *DungeonSession) PreviewMoveByteTransition(nextX, nextY byte) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
transition, _, err := s.previewMoveByteTransitionLocked(nextX, nextY)
|
||||
return transition, err
|
||||
}
|
||||
|
||||
// PreviewMoveMapOverride validates an ordinary room move but projects a
|
||||
// server-selected PVF map at the requested logical coordinate. Revisit remains
|
||||
// coordinate-owned; callers use it to choose a full refresh when this is the
|
||||
// first visit to the replacement map after the base room was already visited.
|
||||
func (s *DungeonSession) PreviewMoveMapOverride(
|
||||
target RoomCoordinate,
|
||||
resolved ResolvedMap,
|
||||
) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
room, revisit, _, err := s.run.previewMoveMapOverride(target, resolved)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, nil
|
||||
}
|
||||
|
||||
// ValidateMoveMapOverride applies the complete fresh/exact-cache validation
|
||||
// without changing the run or active scene.
|
||||
func (s *DungeonSession) ValidateMoveMapOverride(
|
||||
target RoomCoordinate,
|
||||
resolved ResolvedMap,
|
||||
cachedScene *DungeonRoomScene,
|
||||
) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
validated, err := s.validateMoveMapOverrideLocked(target, resolved, cachedScene)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
validated.transition.Scene = cloneDungeonRoomScene(validated.transition.Scene)
|
||||
return validated.transition, nil
|
||||
}
|
||||
|
||||
// CommitMoveMapOverride installs either a fresh replacement scene or the exact
|
||||
// cached scene for that replacement map. A nil cachedScene intentionally means
|
||||
// fresh even when the logical coordinate was visited with a different map.
|
||||
func (s *DungeonSession) CommitMoveMapOverride(
|
||||
target RoomCoordinate,
|
||||
resolved ResolvedMap,
|
||||
cachedScene *DungeonRoomScene,
|
||||
) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
validated, err := s.validateMoveMapOverrideLocked(target, resolved, cachedScene)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
scene := validated.transition.Scene
|
||||
revisit := validated.transition.Revisit
|
||||
if _, err := s.run.commitMoveMapOverride(
|
||||
target,
|
||||
resolved,
|
||||
scene.Map.Map.ID,
|
||||
revisit,
|
||||
scene.Cleared,
|
||||
); err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
if cachedScene != nil {
|
||||
s.installRestoredSceneLocked(scene, validated.bindings, validated.defeated)
|
||||
} else {
|
||||
s.installSceneLocked(scene)
|
||||
}
|
||||
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: revisit}, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) validateMoveMapOverrideLocked(
|
||||
target RoomCoordinate,
|
||||
resolved ResolvedMap,
|
||||
cachedScene *DungeonRoomScene,
|
||||
) (validatedDungeonRoomTransition, error) {
|
||||
room, revisit, targetCleared, err := s.run.previewMoveMapOverride(target, resolved)
|
||||
if err != nil {
|
||||
return validatedDungeonRoomTransition{}, err
|
||||
}
|
||||
expected, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return validatedDungeonRoomTransition{}, err
|
||||
}
|
||||
validated := validatedDungeonRoomTransition{
|
||||
transition: DungeonRoomTransition{Scene: expected, Revisit: revisit},
|
||||
bindings: make(map[HostileReference]uint32, len(expected.ExpectedHostiles)),
|
||||
defeated: make(map[uint32]struct{}, len(expected.ExpectedHostiles)),
|
||||
}
|
||||
if cachedScene == nil {
|
||||
return validated, nil
|
||||
}
|
||||
if !revisit {
|
||||
return validatedDungeonRoomTransition{}, fmt.Errorf(
|
||||
"%w: room=%s map=%d",
|
||||
ErrRevisitSceneUnexpected,
|
||||
expected.Coordinate,
|
||||
expected.Map.Map.ID,
|
||||
)
|
||||
}
|
||||
validated.transition.Scene, validated.bindings, validated.defeated, err = validateRevisitDungeonRoomScene(
|
||||
expected,
|
||||
*cachedScene,
|
||||
targetCleared,
|
||||
)
|
||||
if err != nil {
|
||||
return validatedDungeonRoomTransition{}, err
|
||||
}
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) previewMoveByteTransitionLocked(nextX, nextY byte) (DungeonRoomTransition, bool, error) {
|
||||
target := RoomCoordinate{X: int64(nextX), Y: int64(nextY)}
|
||||
room, revisit, targetCleared, err := s.run.previewMoveToVisit(target)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, false, err
|
||||
}
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, false, err
|
||||
}
|
||||
return DungeonRoomTransition{Scene: scene, Revisit: revisit}, targetCleared, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) MoveByte(nextX, nextY byte) (DungeonRoomScene, error) {
|
||||
transition, err := s.MoveByteTransition(nextX, nextY, nil)
|
||||
return transition.Scene, err
|
||||
}
|
||||
|
||||
// ValidateMoveByteTransition runs the complete first-visit/revisit validation
|
||||
// used by MoveByteTransition without changing the run, active scene, runtime
|
||||
// bindings, or defeated set. Packet owners call this before writing op29.
|
||||
func (s *DungeonSession) ValidateMoveByteTransition(
|
||||
nextX, nextY byte,
|
||||
revisitScene *DungeonRoomScene,
|
||||
) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
validated, err := s.validateMoveByteTransitionLocked(nextX, nextY, revisitScene)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
validated.transition.Scene = cloneDungeonRoomScene(validated.transition.Scene)
|
||||
return validated.transition, nil
|
||||
}
|
||||
|
||||
// MoveByteTransition commits a first visit from PVF data or restores a caller-
|
||||
// owned cached scene for a revisit. A revisit without its exact prior scene is
|
||||
// rejected so the persistent DungeonRun.clear state can never be combined
|
||||
// with freshly respawned actors.
|
||||
func (s *DungeonSession) MoveByteTransition(
|
||||
nextX, nextY byte,
|
||||
revisitScene *DungeonRoomScene,
|
||||
) (DungeonRoomTransition, error) {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonRoomTransition{}, ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
validated, err := s.validateMoveByteTransitionLocked(nextX, nextY, revisitScene)
|
||||
if err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
scene := validated.transition.Scene
|
||||
expectedRevisit := validated.transition.Revisit
|
||||
if _, _, err := s.run.moveToVisit(scene.Coordinate, scene.Cleared, &expectedRevisit); err != nil {
|
||||
return DungeonRoomTransition{}, err
|
||||
}
|
||||
if validated.transition.Revisit {
|
||||
s.installRestoredSceneLocked(scene, validated.bindings, validated.defeated)
|
||||
} else {
|
||||
s.installSceneLocked(scene)
|
||||
}
|
||||
return DungeonRoomTransition{Scene: cloneDungeonRoomScene(s.scene), Revisit: validated.transition.Revisit}, nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) validateMoveByteTransitionLocked(
|
||||
nextX, nextY byte,
|
||||
revisitScene *DungeonRoomScene,
|
||||
) (validatedDungeonRoomTransition, error) {
|
||||
preview, targetCleared, err := s.previewMoveByteTransitionLocked(nextX, nextY)
|
||||
if err != nil {
|
||||
return validatedDungeonRoomTransition{}, err
|
||||
}
|
||||
|
||||
validated := validatedDungeonRoomTransition{
|
||||
transition: preview,
|
||||
bindings: make(map[HostileReference]uint32, len(preview.Scene.ExpectedHostiles)),
|
||||
defeated: make(map[uint32]struct{}, len(preview.Scene.ExpectedHostiles)),
|
||||
}
|
||||
if preview.Revisit {
|
||||
if revisitScene == nil {
|
||||
return validatedDungeonRoomTransition{}, fmt.Errorf(
|
||||
"%w: room=%s map=%d",
|
||||
ErrRevisitSceneRequired,
|
||||
preview.Scene.Coordinate,
|
||||
preview.Scene.Map.Map.ID,
|
||||
)
|
||||
}
|
||||
validated.transition.Scene, validated.bindings, validated.defeated, err = validateRevisitDungeonRoomScene(
|
||||
preview.Scene,
|
||||
*revisitScene,
|
||||
targetCleared,
|
||||
)
|
||||
if err != nil {
|
||||
return validatedDungeonRoomTransition{}, err
|
||||
}
|
||||
} else if revisitScene != nil {
|
||||
return validatedDungeonRoomTransition{}, fmt.Errorf(
|
||||
"%w: room=%s map=%d",
|
||||
ErrRevisitSceneUnexpected,
|
||||
preview.Scene.Coordinate,
|
||||
preview.Scene.Map.Map.ID,
|
||||
)
|
||||
}
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
// Abandon ends the active run while keeping DungeonRun ownership behind the
|
||||
// session lock. Callers use this for an explicit mid-dungeon return to town.
|
||||
func (s *DungeonSession) Abandon() error {
|
||||
if s == nil || s.run == nil {
|
||||
return ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.run.Abandon()
|
||||
}
|
||||
|
||||
// CompleteCurrentRoom commits an authoritative scripted final-room completion
|
||||
// without fabricating actor deaths. The bridge must validate the final-room
|
||||
// protocol event before calling this lifecycle method.
|
||||
func (s *DungeonSession) CompleteCurrentRoom() error {
|
||||
if s == nil || s.run == nil {
|
||||
return ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.run.CompleteCurrentRoom(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.scene.Cleared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkCurrentRoomScriptedCleared records a server-owned scripted room clear
|
||||
// without completing the dungeon run or fabricating deaths for ordinary
|
||||
// hostile actors. Multi-room mechanisms such as the Blood/Ultimate Altars use
|
||||
// this after their map-local round table has been exhausted.
|
||||
func (s *DungeonSession) MarkCurrentRoomScriptedCleared() error {
|
||||
if s == nil || s.run == nil {
|
||||
return ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.scene.Cleared {
|
||||
return nil
|
||||
}
|
||||
if err := s.run.MarkCurrentRoomCleared(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.scene.Cleared = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete closes an ordinary final room after its blocking actors have
|
||||
// authoritatively cleared it. Unlike CompleteCurrentRoom, this method does not
|
||||
// bypass the run's current-room-cleared precondition.
|
||||
func (s *DungeonSession) Complete() error {
|
||||
if s == nil || s.run == nil {
|
||||
return ErrDungeonRunRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.run.Complete()
|
||||
}
|
||||
|
||||
func (s *DungeonSession) Snapshot() DungeonSessionSnapshot {
|
||||
if s == nil || s.run == nil {
|
||||
return DungeonSessionSnapshot{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return DungeonSessionSnapshot{Run: s.run.Snapshot(), Scene: cloneDungeonRoomScene(s.scene)}
|
||||
}
|
||||
|
||||
func (s *DungeonSession) loadRoom(room DungeonRoom) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadRoomLocked(room)
|
||||
}
|
||||
|
||||
func (s *DungeonSession) loadRoomLocked(room DungeonRoom) error {
|
||||
scene, err := dungeonRoomScene(room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if scene.Cleared {
|
||||
if err := s.run.MarkCurrentRoomCleared(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.installSceneLocked(scene)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DungeonSession) installSceneLocked(scene DungeonRoomScene) {
|
||||
s.scene = scene
|
||||
s.bindings = make(map[HostileReference]uint32, len(scene.ExpectedHostiles))
|
||||
s.defeated = make(map[uint32]struct{}, len(scene.ExpectedHostiles))
|
||||
}
|
||||
|
||||
func (s *DungeonSession) installRestoredSceneLocked(
|
||||
scene DungeonRoomScene,
|
||||
bindings map[HostileReference]uint32,
|
||||
defeated map[uint32]struct{},
|
||||
) {
|
||||
s.scene = scene
|
||||
s.bindings = bindings
|
||||
s.defeated = defeated
|
||||
}
|
||||
|
||||
func validateRevisitDungeonRoomScene(
|
||||
expected DungeonRoomScene,
|
||||
cached DungeonRoomScene,
|
||||
runCleared bool,
|
||||
) (DungeonRoomScene, map[HostileReference]uint32, map[uint32]struct{}, error) {
|
||||
if !sameDungeonRoomPVFScene(expected, cached) {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s expected_map=%d cached_room=%s cached_map=%d reason=pvf_scene_changed",
|
||||
ErrRevisitSceneMismatch,
|
||||
expected.Coordinate,
|
||||
expected.Map.Map.ID,
|
||||
cached.Coordinate,
|
||||
cached.Map.Map.ID,
|
||||
)
|
||||
}
|
||||
|
||||
restored := cloneDungeonRoomScene(cached)
|
||||
bindings := make(map[HostileReference]uint32, len(restored.RuntimeObjects))
|
||||
for objectKey, reference := range restored.RuntimeObjects {
|
||||
if objectKey == 0 || !containsHostileReference(restored.ExpectedHostiles, reference) {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s object_key=%d kind=%s index=%d reason=invalid_runtime_binding",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
objectKey,
|
||||
reference.Kind,
|
||||
reference.Index,
|
||||
)
|
||||
}
|
||||
if previous, exists := bindings[reference]; exists {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s kind=%s index=%d object_key=%d previous_object_key=%d reason=duplicate_hostile_binding",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
reference.Kind,
|
||||
reference.Index,
|
||||
objectKey,
|
||||
previous,
|
||||
)
|
||||
}
|
||||
bindings[reference] = objectKey
|
||||
}
|
||||
for _, reference := range restored.ExpectedHostiles {
|
||||
if _, bound := bindings[reference]; !bound {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s kind=%s index=%d reason=expected_hostile_unbound",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
reference.Kind,
|
||||
reference.Index,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
defeated := make(map[uint32]struct{}, len(restored.DefeatedObjects))
|
||||
for _, objectKey := range restored.DefeatedObjects {
|
||||
if _, bound := restored.RuntimeObjects[objectKey]; !bound {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s object_key=%d reason=defeated_object_unbound",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
objectKey,
|
||||
)
|
||||
}
|
||||
if _, duplicate := defeated[objectKey]; duplicate {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s object_key=%d reason=duplicate_defeated_object",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
objectKey,
|
||||
)
|
||||
}
|
||||
defeated[objectKey] = struct{}{}
|
||||
}
|
||||
|
||||
computedCleared := len(restored.BlockingHostiles) == 0
|
||||
if len(restored.BlockingHostiles) != 0 {
|
||||
computedCleared = true
|
||||
for _, reference := range restored.BlockingHostiles {
|
||||
objectKey, bound := bindings[reference]
|
||||
if !bound {
|
||||
computedCleared = false
|
||||
break
|
||||
}
|
||||
if _, dead := defeated[objectKey]; !dead {
|
||||
computedCleared = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if restored.Cleared != computedCleared || restored.Cleared != runCleared {
|
||||
return DungeonRoomScene{}, nil, nil, fmt.Errorf(
|
||||
"%w: room=%s cached_cleared=%t computed_cleared=%t run_cleared=%t reason=clear_state_conflict",
|
||||
ErrRevisitSceneMismatch,
|
||||
restored.Coordinate,
|
||||
restored.Cleared,
|
||||
computedCleared,
|
||||
runCleared,
|
||||
)
|
||||
}
|
||||
return restored, bindings, defeated, nil
|
||||
}
|
||||
|
||||
func sameDungeonRoomPVFScene(expected, cached DungeonRoomScene) bool {
|
||||
expected = cloneDungeonRoomScene(expected)
|
||||
cached = cloneDungeonRoomScene(cached)
|
||||
expected.RuntimeObjects = nil
|
||||
cached.RuntimeObjects = nil
|
||||
expected.DefeatedObjects = nil
|
||||
cached.DefeatedObjects = nil
|
||||
expected.Cleared = false
|
||||
cached.Cleared = false
|
||||
return reflect.DeepEqual(expected, cached)
|
||||
}
|
||||
|
||||
func dungeonRoomScene(room DungeonRoom) (DungeonRoomScene, error) {
|
||||
if room.Map == nil {
|
||||
return DungeonRoomScene{}, ErrRoomMapUnresolved
|
||||
}
|
||||
mapValue := cloneMap(room.Map.Map)
|
||||
scene := DungeonRoomScene{
|
||||
Coordinate: room.Coordinate,
|
||||
Start: room.Start,
|
||||
Boss: room.Boss,
|
||||
Map: ResolvedMap{Map: mapValue, Source: room.Map.Source, SpecificationType: room.Map.SpecificationType},
|
||||
Monsters: append([]MonsterSpawn(nil), mapValue.Monsters...),
|
||||
NPCs: cloneNPCSpawns(mapValue.NPCs),
|
||||
AICharacters: cloneAICharacters(mapValue.AICharacters),
|
||||
PassiveObjects: append([]PassiveObject(nil), mapValue.PassiveObjects...),
|
||||
SpecialPassiveObjects: cloneSpecialPassiveObjects(mapValue.SpecialPassiveObjects),
|
||||
Portals: clonePortals(mapValue.Portals),
|
||||
Summons: cloneMapSummons(mapValue.Summons),
|
||||
RuntimeObjects: make(map[uint32]HostileReference),
|
||||
}
|
||||
for index := range scene.Monsters {
|
||||
reference := HostileReference{Kind: HostileMonster, Index: index}
|
||||
scene.ExpectedHostiles = append(scene.ExpectedHostiles, reference)
|
||||
if monsterSpawnBlocksRoom(mapValue, index) {
|
||||
scene.BlockingHostiles = append(scene.BlockingHostiles, reference)
|
||||
}
|
||||
}
|
||||
for index, actor := range scene.AICharacters {
|
||||
if strings.EqualFold(strings.Trim(actor.Faction, "[] \t"), "monster") {
|
||||
scene.ExpectedHostiles = append(scene.ExpectedHostiles, HostileReference{Kind: HostileAICharacter, Index: index})
|
||||
}
|
||||
}
|
||||
if len(scene.BlockingHostiles) == 0 &&
|
||||
len(mapValue.BloodPhases) == 0 && len(mapValue.UltimatePhases) == 0 {
|
||||
scene.Cleared = true
|
||||
}
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
// A map's [monster team] row is positional to [monster]. Team 0 is the
|
||||
// player/friendly story side and must not keep room doors or tutorial
|
||||
// settlement blocked. Missing entries retain the historical hostile default.
|
||||
func monsterSpawnBlocksRoom(mapValue Map, monsterIndex int) bool {
|
||||
if monsterIndex < 0 || monsterIndex >= len(mapValue.MonsterTeam) {
|
||||
return true
|
||||
}
|
||||
return mapValue.MonsterTeam[monsterIndex] != 0
|
||||
}
|
||||
|
||||
func containsHostileReference(values []HostileReference, want HostileReference) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneDungeonRoomScene(in DungeonRoomScene) DungeonRoomScene {
|
||||
in.Map.Map = cloneMap(in.Map.Map)
|
||||
in.Monsters = append([]MonsterSpawn(nil), in.Monsters...)
|
||||
in.NPCs = cloneNPCSpawns(in.NPCs)
|
||||
in.AICharacters = cloneAICharacters(in.AICharacters)
|
||||
in.PassiveObjects = append([]PassiveObject(nil), in.PassiveObjects...)
|
||||
in.SpecialPassiveObjects = cloneSpecialPassiveObjects(in.SpecialPassiveObjects)
|
||||
in.Portals = clonePortals(in.Portals)
|
||||
in.Summons = cloneMapSummons(in.Summons)
|
||||
in.ExpectedHostiles = append([]HostileReference(nil), in.ExpectedHostiles...)
|
||||
in.BlockingHostiles = append([]HostileReference(nil), in.BlockingHostiles...)
|
||||
in.DefeatedObjects = append([]uint32(nil), in.DefeatedObjects...)
|
||||
runtimeObjects := in.RuntimeObjects
|
||||
in.RuntimeObjects = make(map[uint32]HostileReference, len(runtimeObjects))
|
||||
for key, value := range runtimeObjects {
|
||||
in.RuntimeObjects[key] = value
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneNPCSpawns(in []NPCSpawn) []NPCSpawn {
|
||||
out := append([]NPCSpawn(nil), in...)
|
||||
for index := range out {
|
||||
out[index].Params = append([]int64(nil), out[index].Params...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAICharacters(in []AICharacter) []AICharacter {
|
||||
out := append([]AICharacter(nil), in...)
|
||||
for index := range out {
|
||||
out[index].Params = append([]int64(nil), out[index].Params...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSpecialPassiveObjects(in []SpecialPassiveObject) []SpecialPassiveObject {
|
||||
out := append([]SpecialPassiveObject(nil), in...)
|
||||
for index := range out {
|
||||
out[index].Spawns = append([]SpecialObjectSpawn(nil), out[index].Spawns...)
|
||||
out[index].HellParty = append([]HellPartyEntry(nil), out[index].HellParty...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePortals(in []Portal) []Portal {
|
||||
out := append([]Portal(nil), in...)
|
||||
for index := range out {
|
||||
if out[index].Position != nil {
|
||||
position := *out[index].Position
|
||||
out[index].Position = &position
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMapSummons(in []MapSummon) []MapSummon {
|
||||
out := append([]MapSummon(nil), in...)
|
||||
for index := range out {
|
||||
out[index].Position = append([]int64(nil), out[index].Position...)
|
||||
out[index].Info = cloneExtensions(out[index].Info)
|
||||
out[index].SourceSections = cloneSections(out[index].SourceSections)
|
||||
out[index].UnknownSections = cloneSections(out[index].UnknownSections)
|
||||
out[index].Extensions = cloneExtensions(out[index].Extensions)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user