Files
90CNServer/server/internal/services/dnfbridge/altar_runtime.go
T
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

1642 lines
56 KiB
Go

package dnfbridge
import (
"context"
"errors"
"fmt"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const (
currentDungeonAltarFinalRoundDelay = time.Second
currentDungeonAltarWaveRetryDelay = 100 * time.Millisecond
currentDungeonAltarQuestDropRetryDelay = time.Second
currentDungeonAltarRoomMoveRetryDelay = time.Second
currentDungeonAltarCompletionRetryDelay = time.Second
)
func dungeonAltarGameplayModule() gameplayModuleDefinition {
deathOpcode := uint16(dnfenum.CmdPacketDieBloodMonster)
readyOpcode := uint16(dnfenum.CmdPacketBloodRoundUIPrepareFinish)
difficultyOpcode := uint16(dnfenum.CmdPacketSelectUltimateDifficulty)
return gameplayModuleDefinition{
Name: "dungeon-altar",
LegacyHandlers: map[uint16]gameplayHandler{
deathOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentDungeonAltarDeaths(session, request.Body)
},
readyOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentDungeonAltarReady(session, request.Body)
},
difficultyOpcode: func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentDungeonAltarDifficulty(session, request.Body)
},
},
UpperHandlers: map[uint16]gameplayHandler{
deathOpcode: defaultClassGameplayHandler(
"game-dungeon-altar-death-blocked",
"current_exe_op255_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentDungeonAltarDeaths(session, body)
},
),
readyOpcode: defaultClassGameplayHandler(
"game-dungeon-altar-ready-blocked",
"current_exe_op258_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentDungeonAltarReady(session, body)
},
),
difficultyOpcode: defaultClassGameplayHandler(
"game-dungeon-altar-difficulty-blocked",
"current_exe_op317_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentDungeonAltarDifficulty(session, body)
},
),
},
}
}
func (s *Service) handleCurrentDungeonAltarReady(session *gameSession, body []byte) error {
if len(body) != 0 {
s.logGameEvent(session, "game-dungeon-altar-ready-blocked",
"body_len", len(body),
"reason", "current_exe_op258_requires_empty_body")
return nil
}
if session == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime, scene, ok := currentDungeonAltarOwnerLocked(session)
if !ok {
s.logGameEvent(session, "game-dungeon-altar-ready-blocked", "reason", "active_altar_runtime_missing")
return nil
}
schedule, accepted, err := runtime.altar.acceptReady(runtimeDungeonRoomKeyFromScene(scene), s.gameplayNow())
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-ready-blocked", "reason", "room_generation_mismatch", "error", err)
return nil
}
if !accepted {
s.logGameEvent(session, "game-dungeon-altar-ready-idempotent",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", runtime.altar.Room.Generation)
return nil
}
return s.startCurrentDungeonAltarScheduleLocked(session, runtime, schedule, "client_op258_ready")
}
func (s *Service) handleCurrentDungeonAltarDeaths(session *gameSession, body []byte) error {
sequences, err := decodeCurrentDungeonAltarDeathSequences(body)
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-death-blocked",
"body_len", len(body), "reason", "current_exe_op255_body_malformed", "error", err)
return nil
}
if session == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime, scene, ok := currentDungeonAltarOwnerLocked(session)
if !ok {
s.logGameEvent(session, "game-dungeon-altar-death-blocked", "reason", "active_altar_runtime_missing")
return nil
}
return s.handleCurrentDungeonAltarDeathsLocked(
session,
runtime,
scene,
sequences,
"client_op255_death",
false,
)
}
// handleCurrentDungeonAltarDeathsLocked owns every accepted altar death
// transport after its caller has authenticated a current-room server sequence.
// The native altar op255 and the current client's dynamic-monster op39 writer
// must share this transaction/progression boundary: routing op39 through the
// ordinary monster path would skip the altar sequence owner and leave the
// fifth Endless round permanently active.
//
// The caller must hold session.dungeon.mu.
func (s *Service) handleCurrentDungeonAltarDeathsLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
sequences []uint16,
source string,
dynamicDeathNotification bool,
) error {
if session == nil || runtime == nil || runtime.altar == nil || runtime.Session == nil ||
session.dungeon.runtime != runtime || runtimeDungeonRoomKeyFromScene(scene) != runtime.altar.Room.Key {
s.logGameEvent(session, "game-dungeon-altar-death-blocked",
"source", source,
"reason", "active_altar_runtime_missing")
return nil
}
roomKey := runtimeDungeonRoomKeyFromScene(scene)
preview, err := runtime.altar.previewDeaths(roomKey, sequences)
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-death-blocked",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", runtime.altar.Room.Generation,
"sequences", sequences,
"source", source,
"reason", "server_owned_sequence_validation_failed",
"error", err)
return nil
}
if runtime.altar.questDropRetry != nil {
return s.enqueueCurrentDungeonAltarDeathsBehindRetryLocked(
session,
runtime,
scene,
sequences,
preview,
source,
dynamicDeathNotification,
)
}
if len(preview.Active) != 0 {
if err := s.commitCurrentDungeonAltarQuestDropsLocked(session, runtime, preview.Active); err != nil {
pending, freezeErr := freezeCurrentDungeonAltarQuestDropRetry(
runtime.altar,
sequences,
preview.Active,
dynamicDeathNotification,
)
if freezeErr != nil {
return errors.Join(err, freezeErr)
}
retryErr := s.armCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
pending,
"quest_monster_drop_transaction_failed",
)
s.logGameEvent(session, "game-dungeon-altar-death-blocked",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", runtime.altar.Room.Generation,
"sequence_count", len(preview.Active),
"source", source,
"reason", "quest_monster_drop_transaction_failed_server_retry_armed",
"error", err,
"retry_error", retryErr)
if retryErr != nil {
return errors.Join(err, retryErr)
}
return nil
}
}
if dynamicDeathNotification {
if len(sequences) != 1 {
return errCurrentDungeonAltarRequestMalformed
}
if err := s.sendCurrentDungeonAltarDynamicDeathNotificationLocked(session, sequences[0]); err != nil {
if len(preview.Active) == 0 {
return err
}
pending, freezeErr := freezeCurrentDungeonAltarQuestDropRetry(
runtime.altar,
sequences,
preview.Active,
true,
)
if freezeErr != nil {
return errors.Join(err, freezeErr)
}
retryErr := s.armCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
pending,
"dynamic_monster_op38_write_failed",
)
s.logGameEvent(session, "game-dungeon-altar-death-blocked",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", runtime.altar.Room.Generation,
"sequence", sequences[0],
"source", source,
"reason", "dynamic_monster_op38_write_failed_server_retry_armed",
"error", err,
"retry_error", retryErr)
if retryErr != nil {
return errors.Join(err, retryErr)
}
return nil
}
}
result, err := runtime.altar.applyDeaths(roomKey, sequences)
if err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-altar-death-accepted",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"accepted", result.Accepted,
"duplicate", result.Duplicate,
"active_remaining", len(runtime.altar.activeSequences),
"source", source,
"progress", result.Progress)
progress := result.Progress
if progress == currentDungeonAltarProgressNone && result.Duplicate != 0 &&
currentDungeonAltarCompletionNeedsRetry(runtime, session.selectedCharacterID) {
progress = currentDungeonAltarProgressDungeonComplete
}
return s.processCurrentDungeonAltarProgressLocked(session, runtime, progress, source)
}
// currentDungeonAltarDynamicDeathSequenceLocked recognizes only a sequence
// allocated by the current altar room generation. Active sequences must also
// belong to the currently scheduled round; retired sequences are retained so
// a duplicate op39 remains idempotent instead of falling into ordinary actor
// retirement. A cross-room/stale object key is deliberately not claimed.
//
// The caller must hold session.dungeon.mu.
func currentDungeonAltarDynamicDeathSequenceLocked(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
objectKey uint32,
) (uint16, bool) {
if runtime == nil || runtime.altar == nil || objectKey == 0 || objectKey > uint32(^uint16(0)) {
return 0, false
}
altar := runtime.altar
roomKey := runtimeDungeonRoomKeyFromScene(scene)
if altar.Room.Generation == 0 || altar.Room.Key != roomKey {
return 0, false
}
sequence := uint16(objectKey)
if owner, active := altar.activeSequences[sequence]; active {
if altar.schedule == nil || owner.RoundGeneration != altar.schedule.Generation ||
owner.RoomKey != roomKey || owner.RoomGeneration != altar.Room.Generation {
return 0, false
}
return sequence, true
}
owner, retired := altar.retiredSequences[sequence]
if !retired || owner.RoundGeneration == 0 || owner.RoomKey != roomKey ||
owner.RoomGeneration != altar.Room.Generation {
return 0, false
}
return sequence, true
}
func (s *Service) enqueueCurrentDungeonAltarDeathsBehindRetryLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
sequences []uint16,
preview currentDungeonAltarDeathPreview,
source string,
dynamicDeathNotification bool,
) error {
if runtime == nil || runtime.altar == nil || runtime.altar.questDropRetry == nil {
return errCurrentDungeonAltarQuestDropInvalid
}
altar := runtime.altar
novelDeaths := make([]currentDungeonAltarDeathSequence, 0, len(preview.Active))
for _, death := range preview.Active {
if !currentDungeonAltarRetryOwnsSequence(altar, death.Sequence) {
novelDeaths = append(novelDeaths, death)
}
}
if len(novelDeaths) == 0 {
if dynamicDeathNotification && preview.Duplicate != 0 && len(sequences) == 1 {
if err := s.sendCurrentDungeonAltarDynamicDeathNotificationLocked(session, sequences[0]); err != nil {
return err
}
}
s.logGameEvent(session, "game-dungeon-altar-death-idempotent",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", altar.Room.Generation,
"sequences", sequences,
"pending_batch_count", 1+len(altar.questDropRetryQueue),
"source", source,
"reason", "server_owned_death_already_retired_or_queued")
if altar.questDropRetryTimerName == "" {
return s.scheduleCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
altar.questDropRetry,
"idempotent_death_rearmed_missing_retry_timer",
)
}
return nil
}
novelSequences := make([]uint16, 0, len(novelDeaths))
for _, death := range novelDeaths {
novelSequences = append(novelSequences, death.Sequence)
}
pending, err := freezeCurrentDungeonAltarQuestDropRetry(
altar,
novelSequences,
novelDeaths,
dynamicDeathNotification,
)
if err != nil {
return err
}
if err := enqueueCurrentDungeonAltarQuestDropRetryLocked(altar, pending); err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-altar-death-retry-queued",
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"room_generation", altar.Room.Generation,
"sequence_count", len(novelSequences),
"dynamic_death_notification", dynamicDeathNotification,
"pending_batch_count", 1+len(altar.questDropRetryQueue),
"source", source)
if altar.questDropRetryTimerName == "" {
return s.scheduleCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
altar.questDropRetry,
"queued_death_rearmed_missing_retry_timer",
)
}
return nil
}
func currentDungeonAltarRetryOwnsSequence(altar *currentDungeonAltarRuntime, sequence uint16) bool {
if altar == nil || sequence == 0 {
return false
}
contains := func(pending *currentDungeonAltarQuestDropRetry) bool {
if pending == nil || pending.Room != altar.Room {
return false
}
for _, death := range pending.Deaths {
if death.Sequence == sequence {
return true
}
}
return false
}
if contains(altar.questDropRetry) {
return true
}
for _, pending := range altar.questDropRetryQueue {
if contains(pending) {
return true
}
}
return false
}
func enqueueCurrentDungeonAltarQuestDropRetryLocked(
altar *currentDungeonAltarRuntime,
pending *currentDungeonAltarQuestDropRetry,
) error {
if altar == nil || pending == nil || pending.Room != altar.Room || len(pending.Deaths) == 0 ||
len(pending.Sequences) == 0 {
return errCurrentDungeonAltarQuestDropInvalid
}
if altar.questDropRetry == nil {
altar.questDropRetry = pending
return nil
}
altar.questDropRetryQueue = append(altar.questDropRetryQueue, pending)
return nil
}
func freezeCurrentDungeonAltarQuestDropRetry(
altar *currentDungeonAltarRuntime,
sequences []uint16,
deaths []currentDungeonAltarDeathSequence,
dynamicDeathNotification bool,
) (*currentDungeonAltarQuestDropRetry, error) {
if altar == nil || altar.Room.Generation == 0 || len(sequences) == 0 ||
len(sequences) > currentDungeonAltarMaximumDeathSequences || len(deaths) == 0 || len(deaths) > len(sequences) {
return nil, errCurrentDungeonAltarQuestDropInvalid
}
if dynamicDeathNotification && len(sequences) != 1 {
return nil, errCurrentDungeonAltarQuestDropInvalid
}
for _, death := range deaths {
if death.Sequence == 0 || death.Owner.RoomKey != altar.Room.Key ||
death.Owner.RoomGeneration != altar.Room.Generation || death.Owner.RoundGeneration == 0 ||
death.Owner.MonsterCode <= 0 || death.Owner.QuestDropSeed == 0 {
return nil, errCurrentDungeonAltarQuestDropInvalid
}
}
return &currentDungeonAltarQuestDropRetry{
Room: altar.Room,
Sequences: append([]uint16(nil), sequences...),
Deaths: append([]currentDungeonAltarDeathSequence(nil), deaths...),
DynamicDeathNotification: dynamicDeathNotification,
}, nil
}
func (s *Service) sendCurrentDungeonAltarDynamicDeathNotificationLocked(
session *gameSession,
sequence uint16,
) error {
body, err := buildCurrentDungeonDeathNotificationBody(uint32(sequence), currentDungeonDeathResponseMonster)
if err != nil {
return err
}
return s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketNotifyDieMonster),
body,
0,
)
}
func (s *Service) handleCurrentDungeonAltarDifficulty(session *gameSession, body []byte) error {
difficulty, err := decodeCurrentDungeonAltarDifficulty(body)
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-difficulty-blocked",
"body_len", len(body), "reason", "current_exe_op317_body_malformed", "error", err)
return nil
}
if session == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime, _, ok := currentDungeonAltarOwnerLocked(session)
if !ok {
s.logGameEvent(session, "game-dungeon-altar-difficulty-blocked", "reason", "active_altar_runtime_missing")
return nil
}
altar := runtime.altar
roomGeneration, promptVersion := altar.Room.Generation, altar.difficultyVersion
if !altar.resolveDifficulty(roomGeneration, promptVersion, difficulty) {
s.logGameEvent(session, "game-dungeon-altar-difficulty-idempotent",
"difficulty", difficulty,
"room_generation", roomGeneration,
"prompt_version", promptVersion,
"reason", "selection_or_timeout_already_won")
return nil
}
if altar.difficultyTimerName != "" && s.gameplayTimers != nil {
s.gameplayTimers.Cancel(altar.difficultyTimerName)
altar.difficultyTimerName = ""
}
ack, err := buildCurrentDungeonAltarDifficultyResolvedBody(difficulty)
if err != nil {
return err
}
// Current EXE op317's common success byte is owned by
// sendGameUpperSuccess; the command-specific body is only difficulty.
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketSelectUltimateDifficulty), ack); err != nil {
return err
}
schedule, err := altar.beginRound(s.gameplayNow())
if err != nil {
return err
}
return s.startCurrentDungeonAltarScheduleLocked(session, runtime, schedule, "client_op317_difficulty")
}
func currentDungeonAltarOwnerLocked(
session *gameSession,
) (*runtimeDungeonState, worldmap.DungeonRoomScene, bool) {
if session == nil || session.dungeon.runtime == nil || session.dungeon.runtime.altar == nil || session.dungeon.runtime.Session == nil {
return nil, worldmap.DungeonRoomScene{}, false
}
runtime := session.dungeon.runtime
scene, ok := runtime.Session.Scene()
if !ok || runtimeDungeonRoomKeyFromScene(scene) != runtime.altar.Room.Key {
return nil, worldmap.DungeonRoomScene{}, false
}
return runtime, scene, true
}
func (s *Service) startCurrentDungeonAltarScheduleLocked(
session *gameSession,
runtime *runtimeDungeonState,
schedule *currentDungeonAltarRoundSchedule,
source string,
) error {
if schedule == nil {
return nil
}
body := buildCurrentDungeonAltarRoundIntervalBody(schedule.GlobalRound, schedule.InitialInterval.Milliseconds())
if err := s.sendGameUpperRawClass(session, currentDungeonAltarRoundIntervalMsgID, body, 0); err != nil {
return err
}
if runtime.altar.crazyMonster != nil && schedule.RoundIndex == runtime.altar.crazyMonster.RoundIndex {
crazyBody, err := buildCurrentDungeonAltarCrazyMonsterBody(*runtime.altar.crazyMonster)
if err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, currentDungeonAltarCrazyMonsterMsgID, crazyBody, 0); err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-endless-altar-crazy-monster-sent",
"dungeon_id", runtime.Dungeon.ID,
"room", runtime.altar.Room.Key,
"round_generation", schedule.Generation,
"local_round", schedule.RoundIndex,
"monster_id", runtime.altar.crazyMonster.MonsterCode,
"variant", runtime.altar.crazyMonster.Variant,
"body_source", "reference_server_blood_crazy_monster_current_exe_op213_u32_u8")
}
s.logGameEvent(session, "game-dungeon-altar-round-started",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", runtime.altar.Room.Key,
"room_generation", schedule.RoomGeneration,
"round_generation", schedule.Generation,
"local_round", schedule.RoundIndex,
"global_round", schedule.GlobalRound,
"difficulty", schedule.Difficulty,
"wave_count", len(schedule.Waves))
return s.scheduleCurrentDungeonAltarWaveLocked(session, runtime, schedule.Generation, 0)
}
func (s *Service) scheduleCurrentDungeonAltarWaveLocked(
session *gameSession,
runtime *runtimeDungeonState,
scheduleGeneration uint64,
retryDelay time.Duration,
) error {
if s == nil || session == nil || runtime == nil || runtime.altar == nil || runtime.altar.schedule == nil ||
runtime.altar.schedule.Generation != scheduleGeneration {
return nil
}
altar := runtime.altar
if altar.nextWaveIndex >= len(altar.schedule.Waves) {
return nil
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
waveIndex := altar.nextWaveIndex
delay := altar.schedule.StartedAt.Add(altar.schedule.Waves[waveIndex].Delay).Sub(queue.Now())
if delay < retryDelay {
delay = retryDelay
}
if delay < 0 {
delay = 0
}
roomGeneration := altar.Room.Generation
timerName := fmt.Sprintf("dnf-dungeon-altar-wave:%s:run:%d:room:%d:round:%d:wave:%d", session.connID, runtime.lifecycleToken, roomGeneration, scheduleGeneration, waveIndex)
if altar.waveTimerName != "" {
queue.Cancel(altar.waveTimerName)
}
altar.waveTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
return err
}
return queue.ScheduleAfter(timerName, delay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
"dungeon-altar-wave-timequeue",
characterID,
characterGeneration,
func() error {
return s.fireCurrentDungeonAltarWave(session, runtime, roomGeneration, scheduleGeneration, waveIndex, due)
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-wave-post-failed", "error", postErr)
}
})
}
func (s *Service) fireCurrentDungeonAltarWave(
session *gameSession,
runtime *runtimeDungeonState,
roomGeneration uint64,
scheduleGeneration uint64,
waveIndex int,
due time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.altar == nil || runtime.altar.Room.Generation != roomGeneration ||
runtime.altar.schedule == nil || runtime.altar.schedule.Generation != scheduleGeneration ||
runtime.altar.nextWaveIndex != waveIndex {
return nil
}
runtime.altar.waveTimerName = ""
wave, err := runtime.altar.reserveWave(scheduleGeneration, waveIndex)
if err != nil {
return err
}
body, err := buildCurrentDungeonAltarMonsterSpawnBody(wave)
if err != nil {
return err
}
expiryTimerName, err := s.scheduleCurrentDungeonAltarWaveExpiryLocked(session, runtime, scheduleGeneration, waveIndex, wave)
if err != nil {
if scheduleErr := s.scheduleCurrentDungeonAltarWaveLocked(session, runtime, scheduleGeneration, currentDungeonAltarWaveRetryDelay); scheduleErr != nil {
return errors.Join(err, scheduleErr)
}
return err
}
if err := s.sendGameUpperRawClass(session, currentDungeonAltarMonsterSpawnMsgID, body, 0); err != nil {
s.cancelCurrentDungeonAltarWaveExpiryLocked(runtime, expiryTimerName)
if scheduleErr := s.scheduleCurrentDungeonAltarWaveLocked(session, runtime, scheduleGeneration, currentDungeonAltarWaveRetryDelay); scheduleErr != nil {
return errors.Join(err, scheduleErr)
}
return err
}
progress, err := runtime.altar.commitWave(wave)
if err != nil {
s.cancelCurrentDungeonAltarWaveExpiryLocked(runtime, expiryTimerName)
return err
}
s.logGameEvent(session, "game-dungeon-altar-wave-spawned",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", roomGeneration,
"round_generation", scheduleGeneration,
"wave_index", waveIndex,
"spawn_count", len(wave.Spawns),
"scheduled_due_utc", due.UTC().Format(time.RFC3339Nano))
if runtime.altar.schedule != nil && runtime.altar.nextWaveIndex < len(runtime.altar.schedule.Waves) {
return s.scheduleCurrentDungeonAltarWaveLocked(session, runtime, scheduleGeneration, 0)
}
return s.processCurrentDungeonAltarProgressLocked(session, runtime, progress, "wave_schedule_complete")
}
func (s *Service) scheduleCurrentDungeonAltarWaveExpiryLocked(
session *gameSession,
runtime *runtimeDungeonState,
scheduleGeneration uint64,
waveIndex int,
wave currentDungeonAltarWave,
) (string, error) {
if s == nil || session == nil || runtime == nil || runtime.altar == nil || runtime.altar.schedule == nil ||
runtime.altar.schedule.Generation != scheduleGeneration || waveIndex < 0 ||
waveIndex >= len(runtime.altar.schedule.Waves) || len(wave.Spawns) == 0 {
return "", nil
}
altar := runtime.altar
scheduled := altar.schedule.Waves[waveIndex]
if scheduled.Monster.DurationMilliseconds <= 0 || scheduled.ExpireDelay <= 0 {
return "", nil
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return "", errGameplayTimeQueueUnavailable
}
deadline := altar.schedule.StartedAt.Add(scheduled.ExpireDelay)
delay := deadline.Sub(queue.Now())
if delay < 0 {
delay = 0
}
room := altar.Room
timerName := fmt.Sprintf("dnf-dungeon-altar-expire:%s:run:%d:room:%d:round:%d:wave:%d",
session.connID, runtime.lifecycleToken, room.Generation, scheduleGeneration, waveIndex)
sequences := make([]uint16, len(wave.Spawns))
for index, spawn := range wave.Spawns {
sequences[index] = spawn.Sequence
}
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
return "", err
}
if altar.expiryTimerNames == nil {
altar.expiryTimerNames = make(map[string]struct{})
}
if _, exists := altar.expiryTimerNames[timerName]; exists {
queue.Cancel(timerName)
delete(altar.expiryTimerNames, timerName)
}
if err := queue.ScheduleAfter(timerName, delay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
"dungeon-altar-wave-expiry-timequeue",
characterID,
characterGeneration,
func() error {
return s.fireCurrentDungeonAltarWaveExpiry(
session,
runtime,
room,
scheduleGeneration,
timerName,
sequences,
due,
)
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-wave-expiry-post-failed", "error", postErr)
}
}); err != nil {
return "", err
}
altar.expiryTimerNames[timerName] = struct{}{}
return timerName, nil
}
func (s *Service) fireCurrentDungeonAltarWaveExpiry(
session *gameSession,
runtime *runtimeDungeonState,
room currentDungeonAltarRoomIdentity,
scheduleGeneration uint64,
timerName string,
sequences []uint16,
due time.Time,
) error {
if session == nil || runtime == nil || timerName == "" {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if runtime.altar == nil {
return nil
}
if _, active := runtime.altar.expiryTimerNames[timerName]; !active {
return nil
}
delete(runtime.altar.expiryTimerNames, timerName)
if session.dungeon.runtime != runtime || runtime.altar.Room != room || runtime.altar.schedule == nil ||
runtime.altar.schedule.Generation != scheduleGeneration {
return nil
}
expired, progress, err := runtime.altar.expireWave(room, scheduleGeneration, sequences)
if err != nil {
return err
}
if expired != 0 {
s.logGameEvent(session, "game-dungeon-altar-wave-expired",
"dungeon_id", runtime.Dungeon.ID,
"room", room.Key,
"room_generation", room.Generation,
"round_generation", scheduleGeneration,
"expired", expired,
"active_remaining", len(runtime.altar.activeSequences),
"due_utc", due.UTC().Format(time.RFC3339Nano),
"progress", progress)
}
return s.processCurrentDungeonAltarProgressLocked(session, runtime, progress, "wave_duration_expired")
}
func (s *Service) processCurrentDungeonAltarProgressLocked(
session *gameSession,
runtime *runtimeDungeonState,
progress currentDungeonAltarProgress,
source string,
) error {
if runtime == nil || runtime.altar == nil || progress == currentDungeonAltarProgressNone {
return nil
}
s.cancelCurrentDungeonAltarExpiryTimersLocked(runtime)
altar := runtime.altar
switch progress {
case currentDungeonAltarProgressRoundActive:
schedule, err := altar.beginRound(s.gameplayNow())
if err != nil {
return err
}
return s.startCurrentDungeonAltarScheduleLocked(session, runtime, schedule, source+"_next_round")
case currentDungeonAltarProgressAwaitingDifficulty:
body := buildCurrentDungeonAltarDifficultyPromptBody(altar.completedRounds, currentDungeonAltarDifficultyTimeoutSeconds)
if err := s.sendGameUpperRawClass(session, currentDungeonAltarDifficultyUIMsgID, body, 0); err != nil {
return err
}
return s.scheduleCurrentDungeonAltarDifficultyTimeoutLocked(session, runtime)
case currentDungeonAltarProgressFinalRoundDelay:
return s.scheduleCurrentDungeonAltarFinalRoundLocked(session, runtime)
case currentDungeonAltarProgressRoomComplete:
s.cancelCurrentDungeonAltarTimersLocked(runtime)
if err := runtime.Session.MarkCurrentRoomScriptedCleared(); err != nil {
return err
}
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-altar-progress-committed",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", altar.Room.Key,
"completed_rounds", altar.completedRounds,
"max_rounds", altar.MaxRounds,
"dungeon_complete", false)
return s.advanceCurrentDungeonEndlessAltarRoomLocked(session, runtime, source)
case currentDungeonAltarProgressDungeonComplete:
return s.completeCurrentDungeonAltarLocked(session, runtime, source)
}
return nil
}
func currentDungeonEndlessAltarNextRoom(runtime *runtimeDungeonState) (worldmap.DungeonRoom, error) {
if runtime == nil || runtime.Session == nil || runtime.altar == nil || runtime.Dungeon.ID != 11006 ||
runtime.altar.Kind != worldmap.BloodAltarDungeonKindEndless ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete {
return worldmap.DungeonRoom{}, errCurrentDungeonAltarDefinitionInvalid
}
altar := runtime.altar
scene, ok := runtime.Session.Scene()
if !ok || runtimeDungeonRoomKeyFromScene(scene) != altar.Room.Key {
return worldmap.DungeonRoom{}, fmt.Errorf(
"%w: altar_room=%+v session_room=%s/%d",
errCurrentDungeonAltarRoomMismatch,
altar.Room.Key,
scene.Coordinate,
scene.Map.Map.ID,
)
}
rooms := runtime.Session.Rooms()
var source *worldmap.DungeonRoom
byCoordinate := make(map[worldmap.RoomCoordinate]worldmap.DungeonRoom, len(rooms))
for index := range rooms {
room := rooms[index]
if room.Map != nil && room.ResolutionError == "" {
byCoordinate[room.Coordinate] = room
}
if room.Coordinate == scene.Coordinate && room.Map != nil && room.Map.Map.ID == scene.Map.Map.ID {
source = &rooms[index]
}
}
if source == nil || source.Map == nil || source.ResolutionError != "" {
return worldmap.DungeonRoom{}, fmt.Errorf(
"%w: source=%s/%d frozen_topology_room_missing_or_unresolved",
errCurrentDungeonAltarRoomMismatch,
scene.Coordinate,
scene.Map.Map.ID,
)
}
candidates := make([]worldmap.DungeonRoom, 0, len(source.Neighbors))
seen := make(map[runtimeDungeonRoomKey]struct{}, len(source.Neighbors))
for _, neighbor := range source.Neighbors {
target, found := byCoordinate[neighbor.Coordinate]
if !found || target.Map == nil || target.ResolutionError != "" {
continue
}
key := runtimeDungeonRoomKey{X: target.Coordinate.X, Y: target.Coordinate.Y, MapID: target.Map.Map.ID}
if _, complete := altar.completedRooms[key]; complete {
continue
}
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
candidates = append(candidates, target)
}
if len(candidates) != 1 {
return worldmap.DungeonRoom{}, fmt.Errorf(
"%w: source=%s/%d unfinished_neighbor_count=%d",
errCurrentDungeonAltarRoomMismatch,
scene.Coordinate,
scene.Map.Map.ID,
len(candidates),
)
}
target := candidates[0]
if target.Coordinate.X < 0 || target.Coordinate.X > 255 || target.Coordinate.Y < 0 || target.Coordinate.Y > 255 {
return worldmap.DungeonRoom{}, fmt.Errorf(
"%w: target=%s coordinate_exceeds_current_exe_op211_byte_range",
errCurrentDungeonAltarRoomMismatch,
target.Coordinate,
)
}
return target, nil
}
// advanceCurrentDungeonEndlessAltarRoomLocked reuses the ordinary op211 move
// transaction after a five-round Endless Altar room is authoritatively clear.
// The current EXE accepts this server-owned notification without an op45 gate.
// A write or pre-commit failure leaves both owners on the source room and is
// retried; once the room generation changes, retries become stale no-ops.
func (s *Service) advanceCurrentDungeonEndlessAltarRoomLocked(
session *gameSession,
runtime *runtimeDungeonState,
source string,
) error {
if session == nil || runtime == nil || runtime.altar == nil || runtime.Session == nil ||
session.dungeon.runtime != runtime {
return errCurrentDungeonAltarDefinitionInvalid
}
altar := runtime.altar
sourceRoom := altar.Room
target, targetErr := currentDungeonEndlessAltarNextRoom(runtime)
if targetErr != nil {
s.logGameEvent(session, "game-dungeon-altar-room-transition-deferred",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", sourceRoom.Key,
"reason", "unique_unfinished_neighbor_unavailable",
"error", targetErr)
return s.scheduleCurrentDungeonAltarRoomMoveRetryLocked(session, runtime, sourceRoom, "target_unavailable")
}
expected := runtimeDungeonRoomKey{X: target.Coordinate.X, Y: target.Coordinate.Y, MapID: target.Map.Map.ID}
moveErr := s.handleDungeonMoveRequestLocked(
session,
dungeoncmd.MoveMapRequest{NextX: byte(target.Coordinate.X), NextY: byte(target.Coordinate.Y)},
0,
"server_endless_altar_room_complete",
nil,
)
currentScene, hasScene := runtime.Session.Scene()
currentKey := runtimeDungeonRoomKey{}
if hasScene {
currentKey = runtimeDungeonRoomKeyFromScene(currentScene)
}
if session.dungeon.runtime == runtime && runtime.altar == altar && hasScene && currentKey == expected &&
altar.Room.Key == expected && altar.Room.Generation != sourceRoom.Generation {
s.logGameEvent(session, "game-dungeon-altar-room-transition-committed",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"from", sourceRoom.Key,
"to", expected,
"room_generation", altar.Room.Generation,
"post_commit_error", moveErr)
return nil
}
if session.dungeon.runtime == runtime && runtime.altar == altar && hasScene && currentKey == sourceRoom.Key &&
altar.Room == sourceRoom && altar.progress == currentDungeonAltarProgressRoomComplete {
s.logGameEvent(session, "game-dungeon-altar-room-transition-deferred",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", sourceRoom.Key,
"target", expected,
"reason", "op211_move_not_committed",
"error", moveErr)
return s.scheduleCurrentDungeonAltarRoomMoveRetryLocked(session, runtime, sourceRoom, "op211_move_not_committed")
}
return fmt.Errorf(
"%w: altar op211 transition partial source=%+v expected=%+v scene=%+v altar=%+v move_error=%v",
errCurrentDungeonAltarRoomMismatch,
sourceRoom,
expected,
currentKey,
altar.Room,
moveErr,
)
}
func (s *Service) scheduleCurrentDungeonAltarRoomMoveRetryLocked(
session *gameSession,
runtime *runtimeDungeonState,
room currentDungeonAltarRoomIdentity,
reason string,
) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil || session == nil || runtime == nil || runtime.Session == nil || runtime.altar == nil ||
session.dungeon.runtime != runtime || runtime.Dungeon.ID != 11006 ||
runtime.altar.Kind != worldmap.BloodAltarDungeonKindEndless || runtime.altar.Room != room ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete {
return errGameplayTimeQueueUnavailable
}
altar := runtime.altar
if altar.roomTransitionTimerName != "" {
queue.Cancel(altar.roomTransitionTimerName)
}
altar.roomTransitionVersion = nextCurrentDungeonAltarGeneration(altar.roomTransitionVersion)
retryVersion := altar.roomTransitionVersion
lifecycleToken := runtime.lifecycleToken
timerName := fmt.Sprintf(
"dnf-dungeon-altar-room-move-retry:%s:run:%d:room:%d:retry:%d",
session.connID,
lifecycleToken,
room.Generation,
retryVersion,
)
altar.roomTransitionTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
altar.roomTransitionTimerName = ""
return err
}
if err := queue.ScheduleAfter(timerName, currentDungeonAltarRoomMoveRetryDelay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
"dungeon-altar-room-move-retry-timequeue",
characterID,
characterGeneration,
func() error {
return s.fireCurrentDungeonAltarRoomMoveRetry(
session,
runtime,
lifecycleToken,
room,
retryVersion,
timerName,
due,
)
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-room-move-retry-post-failed", "error", postErr)
}
}); err != nil {
altar.roomTransitionTimerName = ""
return err
}
s.logGameEvent(session, "game-dungeon-altar-room-move-retry-armed",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", room.Generation,
"retry_version", retryVersion,
"delay", currentDungeonAltarRoomMoveRetryDelay,
"reason", reason)
return nil
}
func (s *Service) fireCurrentDungeonAltarRoomMoveRetry(
session *gameSession,
runtime *runtimeDungeonState,
lifecycleToken uint64,
room currentDungeonAltarRoomIdentity,
retryVersion uint64,
timerName string,
_ time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.lifecycleToken != lifecycleToken || runtime.altar == nil ||
runtime.altar.Room != room || runtime.altar.roomTransitionVersion != retryVersion ||
runtime.altar.roomTransitionTimerName != timerName ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete {
return nil
}
runtime.altar.roomTransitionTimerName = ""
return s.advanceCurrentDungeonEndlessAltarRoomLocked(session, runtime, "room_move_retry_timequeue")
}
func (s *Service) completeCurrentDungeonAltarLocked(
session *gameSession,
runtime *runtimeDungeonState,
source string,
) error {
if session == nil || runtime == nil || runtime.altar == nil || runtime.Session == nil ||
session.dungeon.runtime != runtime || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete {
return errCurrentDungeonAltarDefinitionInvalid
}
s.cancelCurrentDungeonAltarTimersLocked(runtime)
snapshot := runtime.Session.Snapshot()
switch snapshot.Run.Status {
case worldmap.DungeonRunActive:
if err := runtime.Session.MarkCurrentRoomScriptedCleared(); err != nil {
return err
}
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
return err
}
if err := runtime.Session.Complete(); err != nil {
return err
}
snapshot = runtime.Session.Snapshot()
case worldmap.DungeonRunCompleted:
// A previous completion attempt may already have closed the run before a
// transient PVF, repository, progression, or socket failure. Continue
// from the altar/settlement stage flags without replaying the run.
default:
return errCurrentDungeonAltarDefinitionInvalid
}
if !currentDungeonRuntimeIsPractice(runtime) && !runtime.clearMapCompletionPhaseAPersisted {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
err := s.persistCurrentDungeonScriptedAltarClearMapCompletionPhaseA(ctx, session, runtime, snapshot)
cancel()
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-clear-map-phase-a-retry-scheduled",
"dungeon_id", runtime.Dungeon.ID,
"source", source,
"error", err)
return s.scheduleCurrentDungeonAltarCompletionRetryLocked(session, runtime, "clear_map_phase_a")
}
}
if !currentDungeonRuntimeIsPractice(runtime) && runtime.clearMapCompletionPhaseAPersisted &&
!runtime.clearMapCompletionNotificationClosed {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
err := s.sendCurrentDungeonClearMapCompletionNotificationLocked(ctx, session, runtime)
cancel()
if err != nil {
s.logGameEvent(session, "game-dungeon-altar-clear-map-notification-retry-scheduled",
"dungeon_id", runtime.Dungeon.ID,
"source", source,
"phase_a_persisted", true,
"active_snapshot_sent", runtime.clearMapCompletionActiveSnapshotSent,
"error", err)
return s.scheduleCurrentDungeonAltarCompletionRetryLocked(session, runtime, "clear_map_notification")
}
}
if err := s.startCurrentDungeonAltarSettlementLocked(session, runtime); err != nil {
s.logGameEvent(session, "game-dungeon-altar-settlement-retry-scheduled",
"dungeon_id", runtime.Dungeon.ID,
"source", source,
"phase_a_persisted", runtime.clearMapCompletionPhaseAPersisted,
"error", err)
return s.scheduleCurrentDungeonAltarCompletionRetryLocked(session, runtime, "settlement")
}
if runtime.altar.settlementRetryTimerName != "" && s.gameplayTimers != nil {
s.gameplayTimers.Cancel(runtime.altar.settlementRetryTimerName)
runtime.altar.settlementRetryTimerName = ""
}
s.logGameEvent(session, "game-dungeon-altar-progress-committed",
"source", source,
"dungeon_id", runtime.Dungeon.ID,
"room", runtime.altar.Room.Key,
"completed_rounds", runtime.altar.completedRounds,
"max_rounds", runtime.altar.MaxRounds,
"clear_map_phase_a_persisted", runtime.clearMapCompletionPhaseAPersisted,
"dungeon_complete", true)
return nil
}
func currentDungeonAltarCompletionNeedsRetry(runtime *runtimeDungeonState, characterID uint16) bool {
if runtime == nil || runtime.altar == nil || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete ||
runtime.Session == nil || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunCompleted {
return false
}
if !currentDungeonRuntimeIsPractice(runtime) &&
(!runtime.clearMapCompletionPhaseAPersisted || !runtime.clearMapCompletionNotificationClosed) {
return true
}
return !completedCurrentDungeonAltarSettlementExitReady(runtime, characterID)
}
func (s *Service) scheduleCurrentDungeonAltarDifficultyTimeoutLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil || runtime == nil || runtime.altar == nil {
return errGameplayTimeQueueUnavailable
}
altar := runtime.altar
roomGeneration, promptVersion := altar.Room.Generation, altar.difficultyVersion
timerName := fmt.Sprintf("dnf-dungeon-altar-difficulty:%s:run:%d:room:%d:prompt:%d", session.connID, runtime.lifecycleToken, roomGeneration, promptVersion)
if altar.difficultyTimerName != "" {
queue.Cancel(altar.difficultyTimerName)
}
altar.difficultyTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
return err
}
return queue.ScheduleAfter(timerName, currentDungeonAltarDifficultyTimeoutSeconds*time.Second, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(session, "dungeon-altar-difficulty-timequeue", characterID, characterGeneration, func() error {
return s.fireCurrentDungeonAltarDifficultyTimeout(session, runtime, roomGeneration, promptVersion, due)
})
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-difficulty-post-failed", "error", postErr)
}
})
}
func (s *Service) fireCurrentDungeonAltarDifficultyTimeout(
session *gameSession,
runtime *runtimeDungeonState,
roomGeneration uint64,
promptVersion uint64,
_ time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.altar == nil {
return nil
}
choice, err := s.chooseDungeonIndex(2)
if err != nil {
return err
}
difficulty := worldmap.BloodAltarDifficultyA
if choice == 1 {
difficulty = worldmap.BloodAltarDifficultyB
}
if !runtime.altar.resolveDifficulty(roomGeneration, promptVersion, difficulty) {
return nil
}
runtime.altar.difficultyTimerName = ""
body, err := buildCurrentDungeonAltarDifficultyResolvedBody(difficulty)
if err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, currentDungeonAltarDifficultyUIMsgID, body, 0); err != nil {
return err
}
schedule, err := runtime.altar.beginRound(s.gameplayNow())
if err != nil {
return err
}
return s.startCurrentDungeonAltarScheduleLocked(session, runtime, schedule, "difficulty_timeout")
}
func (s *Service) scheduleCurrentDungeonAltarFinalRoundLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil || runtime == nil || runtime.altar == nil {
return errGameplayTimeQueueUnavailable
}
altar := runtime.altar
roomGeneration := altar.Room.Generation
roundIndex := altar.nextRoundIndex
timerName := fmt.Sprintf("dnf-dungeon-altar-final:%s:run:%d:room:%d:round:%d", session.connID, runtime.lifecycleToken, roomGeneration, roundIndex)
if altar.finalTimerName != "" {
queue.Cancel(altar.finalTimerName)
}
altar.finalTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
return err
}
return queue.ScheduleAfter(timerName, currentDungeonAltarFinalRoundDelay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(session, "dungeon-altar-final-timequeue", characterID, characterGeneration, func() error {
return s.fireCurrentDungeonAltarFinalRound(session, runtime, roomGeneration, roundIndex, due)
})
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-final-post-failed", "error", postErr)
}
})
}
func (s *Service) fireCurrentDungeonAltarFinalRound(
session *gameSession,
runtime *runtimeDungeonState,
roomGeneration uint64,
roundIndex int,
_ time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.altar == nil || runtime.altar.Room.Generation != roomGeneration ||
runtime.altar.nextRoundIndex != roundIndex || runtime.altar.progress != currentDungeonAltarProgressFinalRoundDelay {
return nil
}
runtime.altar.finalTimerName = ""
runtime.altar.progress = currentDungeonAltarProgressRoundActive
schedule, err := runtime.altar.beginRound(s.gameplayNow())
if err != nil {
return err
}
return s.startCurrentDungeonAltarScheduleLocked(session, runtime, schedule, "final_round_delay")
}
func (s *Service) armCurrentDungeonAltarQuestDropRetryLocked(
session *gameSession,
runtime *runtimeDungeonState,
pending *currentDungeonAltarQuestDropRetry,
reason string,
) error {
if runtime == nil || runtime.altar == nil {
return errCurrentDungeonAltarQuestDropInvalid
}
altar := runtime.altar
if err := enqueueCurrentDungeonAltarQuestDropRetryLocked(altar, pending); err != nil {
return err
}
if altar.questDropRetryTimerName != "" {
return nil
}
return s.scheduleCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
altar.questDropRetry,
reason,
)
}
func (s *Service) scheduleCurrentDungeonAltarQuestDropRetryLocked(
session *gameSession,
runtime *runtimeDungeonState,
pending *currentDungeonAltarQuestDropRetry,
reason string,
) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil || session == nil || runtime == nil || runtime.altar == nil || pending == nil ||
session.dungeon.runtime != runtime || len(pending.Sequences) == 0 || len(pending.Deaths) == 0 ||
pending.Room != runtime.altar.Room {
return errGameplayTimeQueueUnavailable
}
altar := runtime.altar
if altar.questDropRetry != pending {
return errCurrentDungeonAltarQuestDropInvalid
}
if altar.questDropRetryTimerName != "" {
queue.Cancel(altar.questDropRetryTimerName)
}
altar.questDropRetryVersion = nextCurrentDungeonAltarGeneration(altar.questDropRetryVersion)
pending.Version = altar.questDropRetryVersion
retryVersion := pending.Version
room := pending.Room
lifecycleToken := runtime.lifecycleToken
timerName := fmt.Sprintf(
"dnf-dungeon-altar-quest-drop-retry:%s:run:%d:room:%d:retry:%d",
session.connID,
lifecycleToken,
room.Generation,
retryVersion,
)
altar.questDropRetryTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
altar.questDropRetryTimerName = ""
return err
}
if err := queue.ScheduleAfter(timerName, currentDungeonAltarQuestDropRetryDelay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
"dungeon-altar-quest-drop-retry-timequeue",
characterID,
characterGeneration,
func() error {
return s.fireCurrentDungeonAltarQuestDropRetry(
session,
runtime,
lifecycleToken,
room,
retryVersion,
timerName,
due,
)
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-quest-drop-retry-post-failed", "error", postErr)
}
}); err != nil {
altar.questDropRetryTimerName = ""
return err
}
s.logGameEvent(session, "game-dungeon-altar-quest-drop-retry-armed",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", room.Generation,
"retry_version", retryVersion,
"sequence_count", len(pending.Deaths),
"delay", currentDungeonAltarQuestDropRetryDelay,
"reason", reason)
return nil
}
func (s *Service) fireCurrentDungeonAltarQuestDropRetry(
session *gameSession,
runtime *runtimeDungeonState,
lifecycleToken uint64,
room currentDungeonAltarRoomIdentity,
retryVersion uint64,
timerName string,
_ time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.lifecycleToken != lifecycleToken || runtime.altar == nil {
return nil
}
altar := runtime.altar
pending := altar.questDropRetry
if pending == nil || pending.Room != room || altar.Room != room || pending.Version != retryVersion ||
altar.questDropRetryVersion != retryVersion || altar.questDropRetryTimerName != timerName {
return nil
}
altar.questDropRetryTimerName = ""
return s.drainCurrentDungeonAltarDeathRetriesLocked(session, runtime, room)
}
func (s *Service) drainCurrentDungeonAltarDeathRetriesLocked(
session *gameSession,
runtime *runtimeDungeonState,
room currentDungeonAltarRoomIdentity,
) error {
if session == nil || runtime == nil || runtime.altar == nil || runtime.altar.Room != room ||
session.dungeon.runtime != runtime {
return nil
}
altar := runtime.altar
for altar.questDropRetry != nil {
pending := altar.questDropRetry
if pending.Room != room {
return errCurrentDungeonAltarQuestDropInvalid
}
if err := s.commitCurrentDungeonAltarQuestDropsLocked(session, runtime, pending.Deaths); err != nil {
s.logGameEvent(session, "game-dungeon-altar-quest-drop-retry-failed",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", room.Generation,
"retry_version", pending.Version,
"sequence_count", len(pending.Deaths),
"pending_batch_count", 1+len(altar.questDropRetryQueue),
"error", err)
return s.scheduleCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
pending,
"quest_monster_drop_retry_failed",
)
}
if pending.DynamicDeathNotification {
if len(pending.Sequences) != 1 {
return errCurrentDungeonAltarRequestMalformed
}
if err := s.sendCurrentDungeonAltarDynamicDeathNotificationLocked(session, pending.Sequences[0]); err != nil {
s.logGameEvent(session, "game-dungeon-altar-death-notification-retry-failed",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", room.Generation,
"retry_version", pending.Version,
"sequence", pending.Sequences[0],
"pending_batch_count", 1+len(altar.questDropRetryQueue),
"error", err)
return s.scheduleCurrentDungeonAltarQuestDropRetryLocked(
session,
runtime,
pending,
"dynamic_monster_op38_retry_failed",
)
}
}
result, err := altar.applyDeaths(room.Key, pending.Sequences)
if err != nil {
return err
}
if result.Progress != currentDungeonAltarProgressNone {
altar.questDropRetryProgress = result.Progress
}
s.logGameEvent(session, "game-dungeon-altar-death-accepted",
"dungeon_id", runtime.Dungeon.ID,
"room", room.Key,
"accepted", result.Accepted,
"duplicate", result.Duplicate,
"active_remaining", len(altar.activeSequences),
"progress", result.Progress,
"pending_batch_count", 1+len(altar.questDropRetryQueue),
"source", "quest_drop_retry_timequeue")
if len(altar.questDropRetryQueue) == 0 {
altar.questDropRetry = nil
altar.questDropRetryQueue = nil
} else {
altar.questDropRetry = altar.questDropRetryQueue[0]
altar.questDropRetryQueue = altar.questDropRetryQueue[1:]
}
}
progress := altar.questDropRetryProgress
altar.questDropRetryProgress = currentDungeonAltarProgressNone
return s.processCurrentDungeonAltarProgressLocked(
session,
runtime,
progress,
"quest_drop_retry_timequeue",
)
}
func (s *Service) scheduleCurrentDungeonAltarCompletionRetryLocked(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil || session == nil || runtime == nil || runtime.altar == nil || runtime.Session == nil ||
session.dungeon.runtime != runtime || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete {
return errGameplayTimeQueueUnavailable
}
altar := runtime.altar
if altar.settlementRetryTimerName != "" {
queue.Cancel(altar.settlementRetryTimerName)
}
altar.settlementRetryVersion = nextCurrentDungeonAltarGeneration(altar.settlementRetryVersion)
retryVersion := altar.settlementRetryVersion
roomGeneration := altar.Room.Generation
lifecycleToken := runtime.lifecycleToken
timerName := fmt.Sprintf(
"dnf-dungeon-altar-completion-retry:%s:run:%d:room:%d:retry:%d",
session.connID,
lifecycleToken,
roomGeneration,
retryVersion,
)
altar.settlementRetryTimerName = timerName
characterID, characterGeneration, err := gameSessionCharacterEventIdentity(session, session.selectedCharacterID)
if err != nil {
altar.settlementRetryTimerName = ""
return err
}
if err := queue.ScheduleAfter(timerName, currentDungeonAltarCompletionRetryDelay, func(due time.Time) {
postErr := s.postGameSessionCharacterEvent(
session,
"dungeon-altar-completion-retry-timequeue",
characterID,
characterGeneration,
func() error {
return s.fireCurrentDungeonAltarCompletionRetry(
session,
runtime,
lifecycleToken,
roomGeneration,
retryVersion,
timerName,
due,
)
},
)
if postErr != nil && !isClosedGameSessionEventError(postErr) {
s.logGameEvent(session, "game-dungeon-altar-completion-retry-post-failed", "error", postErr)
}
}); err != nil {
altar.settlementRetryTimerName = ""
return err
}
s.logGameEvent(session, "game-dungeon-altar-completion-retry-armed",
"dungeon_id", runtime.Dungeon.ID,
"room_generation", roomGeneration,
"retry_version", retryVersion,
"delay", currentDungeonAltarCompletionRetryDelay,
"reason", reason)
return nil
}
func (s *Service) fireCurrentDungeonAltarCompletionRetry(
session *gameSession,
runtime *runtimeDungeonState,
lifecycleToken uint64,
roomGeneration uint64,
retryVersion uint64,
timerName string,
_ time.Time,
) error {
if session == nil || runtime == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime || runtime.lifecycleToken != lifecycleToken || runtime.altar == nil ||
runtime.altar.Room.Generation != roomGeneration || runtime.altar.settlementRetryVersion != retryVersion ||
runtime.altar.settlementRetryTimerName != timerName || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete {
return nil
}
runtime.altar.settlementRetryTimerName = ""
return s.completeCurrentDungeonAltarLocked(session, runtime, "completion_retry_timequeue")
}
func (s *Service) cancelCurrentDungeonAltarTimersLocked(runtime *runtimeDungeonState) {
if runtime == nil || runtime.altar == nil {
return
}
if s != nil && s.gameplayTimers != nil {
for _, name := range runtime.altar.timerNames() {
if name != "" {
s.gameplayTimers.Cancel(name)
}
}
}
runtime.altar.waveTimerName = ""
runtime.altar.difficultyTimerName = ""
runtime.altar.finalTimerName = ""
runtime.altar.questDropRetryTimerName = ""
runtime.altar.questDropRetry = nil
runtime.altar.questDropRetryQueue = nil
runtime.altar.questDropRetryProgress = currentDungeonAltarProgressNone
runtime.altar.roomTransitionTimerName = ""
runtime.altar.settlementRetryTimerName = ""
runtime.altar.expiryTimerNames = make(map[string]struct{})
}
func (s *Service) cancelCurrentDungeonAltarWaveExpiryLocked(runtime *runtimeDungeonState, timerName string) {
if runtime == nil || runtime.altar == nil || timerName == "" {
return
}
if s != nil && s.gameplayTimers != nil {
s.gameplayTimers.Cancel(timerName)
}
delete(runtime.altar.expiryTimerNames, timerName)
}
func (s *Service) cancelCurrentDungeonAltarExpiryTimersLocked(runtime *runtimeDungeonState) {
if runtime == nil || runtime.altar == nil {
return
}
if s != nil && s.gameplayTimers != nil {
for timerName := range runtime.altar.expiryTimerNames {
if timerName != "" {
s.gameplayTimers.Cancel(timerName)
}
}
}
runtime.altar.expiryTimerNames = make(map[string]struct{})
}