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

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

981 lines
32 KiB
Go

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
}