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