Files
90CNServer/server/internal/services/dnfbridge/dungeon_navigation_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

2042 lines
86 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const currentSelectDungeonEntryLevelTooLowReason byte = 14
const currentSelectDungeonEntryItemMissingReason byte = 17
// 深渊(inferno / 玩家勾选深渊派对)的入场道具失败用 reason 27:客户端 254 路
// switch 的 case 27 才会弹邀请函那条(42462 带道具名 / 27156 通用),并且只有这个
// 分支会 sub_3419C60(1,1) 把界面元素解锁;case 17 的文案是通用的
// "因[%s]缺少入场材料,无法进入『%s』。"(47707),其余入场道具继续用它。
const currentSelectDungeonHellInvitationMissingReason byte = 27
const currentSelectDungeonRaidNotReadyReason byte = 3
const currentSelectDungeonPreparationRejectedReason byte = 3
const currentSelectDungeonDailyEntryLimitReachedReason byte = 0xF6
const currentSelectDungeonPartyFatigueExhaustedReason byte = 254
const currentSelectDungeonFatigueExhaustedReason byte = 255
func (s *Service) handleDungeonSelectUpper(session *gameSession, body []byte) error {
return s.handleDungeonSelectUpperPlanned(session, body, nil, true, false)
}
// handleDungeonSelectUpperForSettlementRetry is the ordinary entry driven by the
// settlement dialog's 再次挑战.
//
// It differs in exactly one check: a party member that is still sitting on its own
// completed run's settlement page is accepted and driven by the server instead of
// being refused for "selector is not ready". Only the attack-party leader owns 再次挑战
// (a member's client offers only 返回城镇), so the leader's single request has to carry
// the team -- which is also how 86JP does it: DungeonEntryHandler's PARTY_LOADING_GATE
// preloads the entry to every member session and waits for their FINISH_LOADING, and
// never asks whether a member's selector page is up (DungeonEntryHandler.cs:2460-2560).
// See prepareRuntimePartyDungeonEntry for the exact scope of the relaxation.
func (s *Service) handleDungeonSelectUpperForSettlementRetry(session *gameSession, body []byte) error {
return s.handleDungeonSelectUpperPlanned(session, body, nil, true, true)
}
type runtimePartyDungeonEntryPlan struct {
dungeonID int64
mazeIndex int
maps map[worldmap.RoomCoordinate]int64
room worldmap.RoomCoordinate
mapID int64
boss worldmap.RoomCoordinate
seed uint32
}
type runtimePartyPreparedDungeonEntry struct {
session *gameSession
runtime *runtimeDungeonState
scene worldmap.DungeonRoomScene
plan runtimePartyDungeonEntryPlan
entryItemUpdates []currentItemListEntry
committed bool
}
func (s *Service) handleDungeonSelectUpperPlanned(
session *gameSession,
body []byte,
prepared *runtimePartyPreparedDungeonEntry,
fanoutParty bool,
retryFromSettlement bool,
) error {
if len(body) != dungeoncmd.SelectDungeonRequestSize {
s.logGameEvent(session, "game-dungeon-select-blocked",
"body_len", len(body),
"expected_body_len", dungeoncmd.SelectDungeonRequestSize,
"reason", "current_exe_op16_request_boundary_mismatch")
return nil
}
request, err := dungeoncmd.DecodeSelectDungeonRequest(body)
if err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"body_len", len(body),
"reason", "current_exe_op16_request_malformed",
"error", err)
return nil
}
partyStateAtRequest := runtimePartyStateSnapshot(session)
partyMemberIndex, partyMemberIndexed := runtimePartyMemberIndex(session.selectedCharacterID, partyStateAtRequest)
s.logGameEvent(session, "game-dungeon-select-request-decoded",
"body_len", len(body),
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"entry_option", request.EntryOption,
"selection_mode", request.SelectionMode,
"runtime_state", request.RuntimeState,
"runtime_token", request.RuntimeToken,
"reserved", request.Reserved,
"party_state", request.PartyState,
"offset16_unproven", request.LeaderObjectKey,
"special_mode", request.SpecialMode,
"source", "current_exe_sub_342EA40_fixed_21_byte_writer")
if err := s.commitPendingDungeonReturnForSceneRequest(
session,
"current_exe_op16_after_pending_town_transition",
); err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"reason", "pending_town_transition_commit_failed",
"error", err)
return nil
}
if prepared == nil {
if handled, err := s.replayDungeonSelectAckForActiveRuntime(session, request, len(body)); handled || err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
// 2026-10-20 实测回滚:在 op29/读条之前提前发技能快照会让形态完全不再出现
// (连“第一次偶然成功”也没了),因此不在这里发送;预绑定仍保持在 mode1 之前。
var runtime *runtimeDungeonState
var scene worldmap.DungeonRoomScene
if prepared != nil {
if prepared.session != session || prepared.runtime == nil || prepared.runtime.Session == nil ||
!sameSelectDungeonRequest(prepared.runtime.Request, request) {
return fmt.Errorf("prepared party dungeon entry does not belong to the target session")
}
runtime, scene = prepared.runtime, prepared.scene
} else {
var err error
runtime, scene, err = s.prepareDungeonRuntime(ctx, session, request)
if err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"reason", "dungeon_owner_validation_failed",
"error", err)
// 卢克的路线锁拒绝必须显式告诉玩家:客户端不会在另一条路线的门上画锁,
// 不给提示的话这次点击就是弹回选图页、毫无解释。
if errors.Is(err, errRuntimeRaidLukeRouteLocked) {
if noticeErr := s.sendNotice(session, noticeRequest{
Surface: noticeSurfaceOverlay,
Text: currentRaidLukeRouteLockedNoticeText,
Color: currentClientNoticeDefaultColor,
Source: "luke_phase_two_route_locked",
}); noticeErr != nil {
s.logGameEvent(session, "game-raid-luke-route-notice-failed",
"dungeon_id", request.DungeonID, "error", noticeErr)
}
}
if reason, reply := selectDungeonFailureReason(err); reply {
if errors.Is(err, errDungeonFatigueExhausted) {
return s.sendSelectDungeonFatigueFailure(
session,
selectDungeonFatiguePromptReason(session, partyStateAtRequest),
)
}
return s.sendSelectDungeonFailure(session, reason)
}
return nil
}
}
runtime.partyMemberIndex, runtime.partyMemberIndexed = partyMemberIndex, partyMemberIndexed
// A raid member moving between its own runs of the active phase -- the energy
// interception gate's wall warp into a hatchery (218 -> 221..224), or the same
// completed gate's retry -- arrives as an ordinary op16, but the TOWN co-presence
// preflight cannot apply to it: that preflight demands the whole party stand in
// one town area, which is impossible while they are inside the raid. Routing the
// warp through it is what once refused the requesting member and then threw its
// client back to town: live 2026-09-19 14:36:30Z / 14:39:15Z / 14:41:14Z, raid
// 1507338 ("孵化中心只能进去一个人,其他的人强制返回城镇").
//
// What must NOT change is that this is a PARTY entry. 安徒恩的孵化场正常就是组队进的:
// the squad walks to the portal together and the whole squad changes dungeon. Three
// earlier shapes were all wrong, and the live logs say why:
//
// * refusing when the party could not be assembled (the original bug), and
// * letting the opener warp solo (live 2026-09-19 23:36:14Z, raid 1507329:
// char 1 entered 222 alone, char 22 stayed in 218 for 235 s with every op45
// consumed as shared_room_transition_is_leader_owned until the server forced
// it back to town), and
// * driving the squadmates separately AFTER a solo opener (live 2026-09-20
// 00:50:37Z: the party entry parks an all-member registration barrier in
// emitRuntimePartyDungeonStart, so an opener that never registers leaves its
// own client waiting on the loading gate and its squadmate stopped right
// after op5 -- neither of them could enter).
//
// So the warp stays an ordinary party entry end to end, with only the two gates
// that cannot hold inside a raid relaxed (see prepareRuntimePartyDungeonEntry's
// inRunTransfer argument). A party that cannot be prepared is REFUSED, never split:
// everyone stays together in the gate. The solo path below is reached only when
// there is no party to carry (PartyID <= 0, or a one-member party), which is not a
// fallback but the absence of a party.
inRunTransfer := false
if prepared == nil && fanoutParty && s.currentRaidInRunTransferEntry(session, request) {
s.logGameEvent(session, "game-raid-in-run-transfer-entry",
"char_id", session.selectedCharacterID,
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "raid_member_transfers_inside_its_own_gate_as_a_party_entry")
inRunTransfer = true
}
var preparedFollowers []*runtimePartyPreparedDungeonEntry
if prepared == nil && fanoutParty {
var err error
preparedFollowers, err = s.prepareRuntimePartyDungeonEntry(
ctx, session, partyStateAtRequest, request, runtime, scene, retryFromSettlement, inRunTransfer)
if err != nil {
s.logGameEvent(session, "game-party-dungeon-entry-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"difficulty", request.Difficulty,
"reason", "party_entry_preflight_failed_before_any_member_commit",
"error", err)
if errors.Is(err, errDungeonFatigueExhausted) {
return s.sendSelectDungeonFatigueFailure(
session,
currentSelectDungeonPartyFatigueExhaustedReason,
)
}
return s.sendSelectDungeonFailure(session, currentDungeonAdmissionCounterFailureReason(err))
}
}
if prepared == nil && len(preparedFollowers) > 0 {
if err := s.attachRuntimePartyDungeonOwner(session, runtime, preparedFollowers, partyStateAtRequest); err != nil {
s.logGameEvent(session, "game-party-dungeon-entry-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"difficulty", request.Difficulty,
"reason", "shared_party_dungeon_owner_preflight_failed",
"error", err)
return s.sendSelectDungeonFailure(session, currentSelectDungeonPreparationRejectedReason)
}
}
if err := freezeCurrentDungeonTownReturnOrigin(session, runtime); err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "ordinary_dungeon_town_return_origin_unavailable",
"error", err)
return nil
}
initialVisit, err := runtime.currentDungeonRoomVisit(scene)
if err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "initial_room_visit_unavailable_for_passive_object_drop_plan",
"error", err)
return nil
}
passiveDropPlan, err := s.planCurrentDungeonPassiveObjectDrops(
session,
runtime,
scene,
runtime.NextObjectKey,
initialVisit.DropRNG,
)
if err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "initial_passive_object_drop_preflight_failed",
"error", err)
return nil
}
if _, err := buildCurrentDungeonEntryPacketsWithPassiveDrops(runtime, scene, passiveDropPlan.ExtraEntries); err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "dungeon_entry_packet_preflight_failed",
"error", err)
return nil
}
// The direct-button transitions (training room 5000 and job-change trial
// experience 7142, state 1, token UINT16_MAX, solo) carry the op15 actor
// straight into op28/op29; replaying the ordinary op16 ACK/actor rebind/op27
// chain on them builds a second scene owner. They must also bypass the
// ordinary admission-counter, altar and party-item commitments below: the
// reference flow enters these guided rooms with no resource consumption.
if prepared == nil && partyStateAtRequest.PartyID <= 0 && isCurrentDirectDungeonEntryRequest(request) {
if err := s.commitDungeonRuntime(session, runtime); err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "dungeon_runtime_commit_failed_before_direct_entry",
"error", err)
return s.sendSelectDungeonFailure(session, currentSelectDungeonPreparationRejectedReason)
}
return s.sendCurrentPungjinTrainingDirectEntry(session, runtime, scene, passiveDropPlan)
}
var dungeonAdmissionCounterBatch *currentDungeonAdmissionCounterBatch
if prepared == nil && !currentDungeonRuntimeIsPractice(runtime) {
dungeonAdmissionCounterBatch, err = s.reserveCurrentDungeonAdmissionCounters(
ctx, session, runtime, preparedFollowers)
if err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"party_member_count", len(preparedFollowers)+1,
"reason", "party_admission_counter_preflight_or_reserve_failed_before_inventory_commit",
"error", err)
// 【2026-10-12 修正】这里原来一律回
// currentSelectDungeonPreparationRejectedReason(通用"准备失败"),
// 把"每日入场次数用尽"也归进了通用失败。同一文件组队路径(226 行)早已
// 用 currentDungeonAdmissionCounterFailureReason(err),它会把
// dungeonadmission.LimitError 映射成专用的
// currentSelectDungeonDailyEntryLimitReachedReason(0xF6,客户端显示
// "今日入场次数已用尽")。周常五图是**单人限定**(PVF [limit party count] 1),
// 玩家只会走这条单人路径,所以此前撞额度上限时看到的是含糊的通用提示 ——
// 报告要求的"额度耗尽投影为不可进入 + 服务端重验并给出原生提示"因此没有落地。
// 改成与组队路径同一映射:额度类错误给专用提示,其余仍是通用提示。
return s.sendSelectDungeonFailure(session, currentDungeonAdmissionCounterFailureReason(err))
}
}
var altarAdmission *currentDungeonAltarAdmissionReservation
if prepared == nil && !currentDungeonRuntimeIsPractice(runtime) {
altarAdmission, err = s.reserveCurrentDungeonAltarAdmission(ctx, session, runtime)
if err != nil {
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(dungeonAdmissionCounterBatch); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"reason", "altar_admission_gold_or_counter_reserve_failed",
"error", err)
return s.sendSelectDungeonFailure(session, currentSelectDungeonPreparationRejectedReason)
}
}
var dungeonEntryItemUpdates []currentItemListEntry
var dungeonEntryInventoryBatch *currentDungeonEntryInventoryBatch
if prepared != nil {
dungeonEntryItemUpdates = append([]currentItemListEntry(nil), prepared.entryItemUpdates...)
} else if !currentDungeonRuntimeIsPractice(runtime) {
dungeonEntryInventoryBatch, err = s.consumeCurrentDungeonEntryPartyItems(
ctx, session, runtime, preparedFollowers)
if err != nil {
if restoreErr := s.restoreCurrentDungeonAltarAdmission(altarAdmission); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(dungeonAdmissionCounterBatch); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"party_member_count", len(preparedFollowers)+1,
"reason", "party_admission_item_missing_or_consume_failed_before_any_runtime_commit",
"error", err)
if errors.Is(err, errDungeonEntryItemMissing) {
return s.sendCurrentDungeonEntryItemMissingFailure(session, runtime, err)
}
// 走到这里的是入场计划本身建立不起来(例如 PVF 里既没有
// [hell party required item count] 也没有 [minimum required level])。
// 以前这里 return nil:服务端一个字都不回,客户端停在选择页上不动
// (2026-09-26 现场 193 号副本卡了 26 秒)。失败必须让玩家看见。
return s.sendCurrentDungeonEntryAdmissionPlanFailure(session, runtime, err)
}
dungeonEntryItemUpdates = dungeonEntryInventoryBatch.updatesFor(session)
}
if prepared == nil && len(preparedFollowers) > 0 {
if err := s.commitRuntimePartyPreparedFollowers(preparedFollowers); err != nil {
if restoreErr := s.restoreCurrentDungeonEntryInventoryBatch(dungeonEntryInventoryBatch); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
if restoreErr := s.restoreCurrentDungeonAltarAdmission(altarAdmission); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(dungeonAdmissionCounterBatch); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
s.logGameEvent(session, "game-party-dungeon-entry-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"difficulty", request.Difficulty,
"reason", "party_follower_runtime_commit_failed_before_leader_commit",
"error", err)
return s.sendSelectDungeonFailure(session, currentSelectDungeonPreparationRejectedReason)
}
}
if prepared == nil || !prepared.committed {
if err := s.commitDungeonRuntimeWithPartyAdmission(
session,
runtime,
preparedFollowers,
dungeonEntryInventoryBatch,
dungeonAdmissionCounterBatch,
); err != nil {
if restoreErr := s.restoreCurrentDungeonAltarAdmission(altarAdmission); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "dungeon_runtime_commit_failed",
"error", err)
return s.sendSelectDungeonFailure(session, currentSelectDungeonPreparationRejectedReason)
}
}
if runtime.partyDungeonOwner != nil {
if err := s.emitRuntimePartyDungeonStart(
session,
runtime,
scene,
body,
passiveDropPlan,
dungeonEntryItemUpdates,
prepared == nil,
retryFromSettlement,
); err != nil {
// The client answers a failed entry with nothing at all, so this
// return cannot stay silent: everything before it succeeded and the
// selected dungeon is already committed.
s.logGameEvent(session, "game-party-dungeon-entry-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"party_id", runtime.partyDungeonOwner.partyID,
"maze_index", runtime.MazeIndex,
"reason", "shared_party_dungeon_start_room_emit_failed_after_commit",
"error", err)
return err
}
if fanoutParty {
if err := s.enterRuntimePartyFollowers(session, body, preparedFollowers, partyStateAtRequest, retryFromSettlement); err != nil {
s.logGameEvent(session, "game-party-dungeon-entry-blocked",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"party_id", runtime.partyDungeonOwner.partyID,
"maze_index", runtime.MazeIndex,
"reason", "shared_party_dungeon_follower_enter_failed_after_leader_commit",
"error", err)
return err
}
}
s.leaveTownPresenceForDungeon(session)
return nil
}
resetDungeonEntrySceneGates(session)
if err := s.sendCurrentSceneOp9PreviewActorRemovalOnce(session, "select_dungeon_after_runtime_commit_before_op27"); err != nil {
s.logGameEvent(session, "game-dungeon-select-blocked",
"dungeon_id", request.DungeonID,
"maze_index", runtime.MazeIndex,
"reason", "select_preview_actor_removal_failed_after_runtime_commit",
"error", err)
return err
}
if prepared == nil {
selectAckBody := buildSelectDungeonBodyForCharacter(session.selectedCharacterID)
s.logGameEvent(session, "game-dungeon-select-ack-send",
"msg_id", uint16(dnfenum.CmdPacketSelectDungeon),
"body_len", len(selectAckBody)+1,
"compatibility_mode", selectAckBody[0],
"compatibility_object_key", binary.LittleEndian.Uint16(selectAckBody[1:3]),
"request_body_len", len(body),
"source", "current_exe_sub_1CFC410_generic_success_byte_trailing_body_ignored",
"reason", "stop_rejected_op16_request_echo")
if err := s.sendGameUpperSuccess(
session,
uint16(dnfenum.CmdPacketSelectDungeon),
selectAckBody,
); err != nil {
return err
}
} else {
s.logGameEvent(session, "game-party-dungeon-select-follower-ack-suppressed",
"msg_id", uint16(dnfenum.CmdPacketSelectDungeon),
"request_body_len", len(body),
"reason", "follower_did_not_submit_op16_and_receives_leader_owned_op28_op29_only")
}
if altarAdmission != nil {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
buildCurrentGoldStateBody(altarAdmission.snapshot.GoldAfter),
0,
); err != nil {
return err
}
}
if len(dungeonEntryItemUpdates) != 0 {
if err := s.sendGameUpperRawClass(session,
uint16(dnfenum.CmdPacketWalkoutPartyMember),
buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, dungeonEntryItemUpdates), 0); err != nil {
return err
}
}
if err := s.sendCurrentDungeonResourceState(session, runtime, "select_dungeon_after_op16_ack_before_op27"); err != nil {
return err
}
if err := s.sendCurrentPreDungeonContextPlayerState(session, "select_dungeon_after_op5_before_op27"); err != nil {
return err
}
if !session.enterSelectDungeonContextSent {
if err := s.sendCurrentDungeonContextOp27(session, "select_dungeon_after_pre_actor_binding"); err != nil {
return err
}
session.enterSelectDungeonContextSent = true
} else {
s.logGameEvent(session, "game-upper-current-dungeon-context-op27-duplicate-skipped",
"source", "select_dungeon_after_pre_actor_binding",
"char_id", session.selectedCharacterID,
"msg_id", currentDungeonContextMsgID,
"reason", "town_enter_select_page_already_committed_before_real_op16")
}
entryPackets, err := buildCurrentDungeonEntryPacketsWithPassiveDrops(runtime, scene, passiveDropPlan.ExtraEntries)
if err != nil {
s.logGameEvent(session, "game-upper-current-dungeon-info-op28-deferred",
"source", "select_dungeon_after_op27",
"char_id", session.selectedCharacterID,
"msg_id", currentDungeonInfoNotification,
"dungeon_id", runtime.Dungeon.ID,
"reason", "typed_runtime_packet_plan_failed",
"error", err)
return nil
}
s.logGameEvent(session, "game-upper-current-dungeon-info-op28-send",
"source", "select_dungeon_after_op27",
"char_id", session.selectedCharacterID,
"msg_id", currentDungeonInfoNotification,
"classification", 0,
"body_len", len(entryPackets.DungeonInfo),
"dungeon_id", runtime.Dungeon.ID,
"difficulty", runtime.Request.Difficulty,
"entry_option", runtime.Request.EntryOption,
"maze_index", runtime.MazeIndex,
"boss", runtime.BossCoordinate.String(),
"body_source", "current_exe_sub_1D440C0_real_pvf_runtime",
"start_map", "deferred_until_op28_runtime_acceptance")
if err := s.sendGameUpperRawClass(session, currentDungeonInfoNotification, entryPackets.DungeonInfo, 0); err != nil {
return err
}
if err := s.sendCurrentTowerOfDespairOpponent(session, runtime); err != nil {
return fmt.Errorf("initialize despair tower opponent before op29: %w", err)
}
if entryPackets.RandomizedCreatureCount != 0 {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketVerifyCreatureQuest),
entryPackets.RandomizedCreatureQueue,
0,
); err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-randomized-neutral-creature-queued",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"creature_count", entryPackets.RandomizedCreatureCount,
"packet_order", "op28-selection-op479-explicit-creature-op29-room")
}
if isCurrentForgottenLandDungeon(runtime) {
// The current EXE initializes the Lost Land countdown while processing
// START_MAP, so its two-u32 PVF limit projection must precede op29.
if err := s.sendCurrentForgottenLandTimeProjection(session, runtime); err != nil {
return fmt.Errorf("send forgotten land time projection before op29: %w", err)
}
if err := s.armCurrentForgottenLandTimer(session, runtime); err != nil {
return fmt.Errorf("arm forgotten land gameplay timer before op29: %w", err)
}
}
roomSnapshot := runtime.Room.Snapshot()
s.logGameEvent(session, "game-upper-current-dungeon-start-op29-send",
"source", "select_dungeon_after_op28",
"char_id", session.selectedCharacterID,
"msg_id", currentDungeonStartMapNotificationForRuntime(runtime),
"classification", 0,
"body_len", len(entryPackets.StartMap),
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"seed", runtime.Seed,
"actor_count", len(roomSnapshot.Monsters)+len(roomSnapshot.ExtendedActors),
"body_source", "current_exe_sub_1D479A0_real_pvf_runtime")
if err := s.sendGameUpperRawClass(session, currentDungeonStartMapNotificationForRuntime(runtime), entryPackets.StartMap, 0); err != nil {
session.dungeon.mu.Lock()
if session.dungeon.runtime == runtime {
s.cancelCurrentForgottenLandTimerLocked(session, runtime, "start_map_socket_write_failed")
}
session.dungeon.mu.Unlock()
return err
}
// 新建角色第一次进入地图:播放一次性告示片(补丁内置播放器)。
s.maybePlayFirstMapCutscene(session)
// 每次进入新副本时重置 HP/MP 药水冷却,避免沿用上一次进图的窗口。
s.resetHealingPotionCooldownsOnDungeonEntry(session, "select_dungeon_after_op29")
if err := s.publishCurrentDungeonTrapEventMonsterAfterStartMap(
session,
runtime,
scene,
runtime.Room,
"select_dungeon_after_op29",
); err != nil {
return fmt.Errorf("publish current trap event-monster condition after op29: %w", err)
}
s.runCurrentDungeonActionObjectsLocked(session, runtime, scene, runtimeDungeonActionObjectRoomEntered, "select_dungeon_after_op29")
if err := s.sendCurrentDungeonEpicWishSnapshot(
session,
runtime,
"select_dungeon_after_op29",
); err != nil {
return fmt.Errorf("send current Time Rift epic-wish snapshot after op29: %w", err)
}
if err := s.sendCurrentTournamentEntryProjection(session, runtime, scene); err != nil {
return fmt.Errorf("send current tournament entry projection after op29: %w", err)
}
announcedActors, err := runtime.Room.AnnounceAllActors(runtime.Session)
if err != nil {
return fmt.Errorf("commit current start-map runtime actors after op29 send: %w", err)
}
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
return fmt.Errorf("cache current start-map runtime room after actor announce: %w", err)
}
if err := commitCurrentDungeonPassiveObjectDrops(runtime, scene, passiveDropPlan); err != nil {
return fmt.Errorf("commit current start-map passive-object drops after op29 send: %w", err)
}
if err := s.consumeCurrentDungeonRoomFatigueAfterCommit(session, runtime, "select_dungeon_after_op29_commit"); err != nil {
return fmt.Errorf("consume current start-map fatigue: %w", err)
}
s.logGameEvent(session, "game-upper-current-dungeon-start-op29-committed",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"announced_actor_count", announcedActors,
"passive_object_item_rows", passiveDropPlan.ConfiguredRowCount,
"passive_object_drop_count", len(passiveDropPlan.ExtraEntries))
if err := s.sendCurrentPostStartMapPlayerPlacement(session, runtime, scene, "select_dungeon_after_op29_commit"); err != nil {
return fmt.Errorf("send current post-start-map player state: %w", err)
}
// 支援兵:把本副本冻结的视觉投影(私有 opcode 0x7F41)发给客户端。
// 位置必须在这里 —— 玩家落位之后、任何后续场景包之前,这样客户端
// 在建立本地 actor 之后才收到支援兵投影。
if err := s.sendFrozenCurrentDungeonCompanionProjection(
session, runtime, "select_dungeon_after_op29_and_player_placement"); err != nil {
return fmt.Errorf("send current companion visual projection: %w", err)
}
// Carry the weakness into the dungeon. The condition is published once per
// login by subtype0, and entering a dungeon rebuilds the local player, so
// without this the health cap was dropped at the door and the penalty only
// applied in town. Sent after the placement above, for the same reason the
// town route sends it after its rebuild. 修炼场 and PVP are exempt: a
// whitelisted room neither applies nor carries the penalty. This used to
// guard on currentDungeonRuntimeIsPractice, which is the op16 protocol mode
// and never true for the training room's own direct-entry button - see
// currentWeaknessExemptRoom.
if _, exempt := currentWeaknessExemptRoom(session, runtime); !exempt {
if err := s.sendCurrentWeaknessConditionRefresh(session, "select_dungeon_after_op29_commit"); err != nil {
return fmt.Errorf("send current weakness condition after op29: %w", err)
}
}
if err := s.armCurrentCrystalContractTimer(session, runtime); err != nil {
// Crystal Contract is optional premium state. A repository or scheduler
// outage must not strand an otherwise valid dungeon entry after op29.
s.logGameEvent(session, "game-crystal-contract-entry-timer-deferred",
"dungeon_id", runtime.Dungeon.ID,
"run_token", runtime.lifecycleToken,
"error", err)
}
s.logGameEvent(session, "game-dungeon-runtime-prepared",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"map_path", scene.Map.Map.Path,
"monsters", len(scene.Monsters),
"hostiles", len(scene.ExpectedHostiles),
"blocking_hostiles", len(scene.BlockingHostiles),
"npcs", len(scene.NPCs),
"passive_objects", len(scene.PassiveObjects)+len(scene.SpecialPassiveObjects),
"summons", len(scene.Summons),
"runtime_monsters", runtime.Room.MonsterCount(),
"opaque_hostiles", runtime.Room.OpaqueHostileCount(),
"tutorial_completed_reentry", runtime.tutorialCompletedReentry,
"entry_sequence", "requester_class1_op16_ack_or_party_follower_no_ack_then_class0_op5_then_final_owner_mode0_actor_binding_mode1_then_op27_page_enter_runtime_recompute_gate_then_op28_optional_lost_land_op603_and_gameplay_timer_then_op29_optional_trap_event_op312_then_full_equipment_mode1_mode3_optional_pvf_tutorial_op3_user_state_op359_op356_op124_op9_op120_then_optional_crystal_contract_timer",
"deferred", "real_hud_and_remaining_scene_objects")
if fanoutParty {
if err := s.enterRuntimePartyFollowers(session, body, preparedFollowers, partyStateAtRequest, retryFromSettlement); err != nil {
return err
}
}
s.leaveTownPresenceForDungeon(session)
return nil
}
func (s *Service) commitDungeonRuntimeWithPartyAdmission(
session *gameSession,
runtime *runtimeDungeonState,
preparedFollowers []*runtimePartyPreparedDungeonEntry,
inventory *currentDungeonEntryInventoryBatch,
counterBatches ...*currentDungeonAdmissionCounterBatch,
) error {
var counters *currentDungeonAdmissionCounterBatch
if len(counterBatches) > 0 {
counters = counterBatches[0]
}
err := s.commitDungeonRuntime(session, runtime)
if err == nil {
return nil
}
s.rollbackRuntimePartyPreparedFollowers(preparedFollowers)
if restoreErr := s.restoreCurrentDungeonEntryInventoryBatch(inventory); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(counters); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
return err
}
func selectDungeonFailureReason(err error) (byte, bool) {
switch {
case err == nil:
return 0, false
case errors.Is(err, dnfdungeon.ErrEntryLevelTooLow):
return currentSelectDungeonEntryLevelTooLowReason, true
case errors.Is(err, dnfdungeon.ErrEntryPartyLimit):
// The current client has no dedicated party-limit reason in this flow.
// Reason 3 is the verified retryable selector rejection and is also used
// for the solo-only Blood/Ultimate Altar implementation boundary.
return currentSelectDungeonPreparationRejectedReason, true
case errors.Is(err, errDungeonFatigueExhausted):
return currentSelectDungeonFatigueExhaustedReason, true
case errors.Is(err, errDungeonAlreadyActive):
// A different op16 arriving after the runtime is active is a stale client
// request, not a selector preparation failure. Replying would reopen or
// mutate UI state while the character is already inside the dungeon.
return 0, false
case errors.Is(err, errAntonRaidEntryBlocked):
return currentSelectDungeonRaidNotReadyReason, true
case errors.Is(err, dnfhellparty.ErrRoomUnavailable), errors.Is(err, dnfhellparty.ErrRoomInvalid):
// The current client understands this established retryable op16 rejection
// shape and returns to the selector instead of waiting for a start-map packet.
return currentSelectDungeonPreparationRejectedReason, true
default:
// Once the current client has sent SELECT_DUNGEON it waits for either
// the normal entry stream or an op16 rejection. Returning silently on
// an unknown PVF/map/runtime preparation error leaves the selector in
// an infinite loading state. Reason 3 is the proven retryable rejection
// that returns this EXE to the dungeon selector.
return currentSelectDungeonPreparationRejectedReason, true
}
}
func selectDungeonFatiguePromptReason(
session *gameSession,
state alignedcmd.PartyState,
) byte {
if session != nil && state.PartyID > 0 &&
state.UserID == session.selectedCharacterID &&
len(runtimePartyMembers(state)) > 1 {
return currentSelectDungeonPartyFatigueExhaustedReason
}
return currentSelectDungeonFatigueExhaustedReason
}
func (s *Service) prepareRuntimePartyDungeonEntry(
ctx context.Context,
leader *gameSession,
state alignedcmd.PartyState,
request dungeoncmd.SelectDungeonRequest,
leaderRuntime *runtimeDungeonState,
leaderScene worldmap.DungeonRoomScene,
retryFromSettlement bool,
inRunTransfer bool,
) ([]*runtimePartyPreparedDungeonEntry, error) {
if leader == nil || state.PartyID <= 0 || state.UserID != leader.selectedCharacterID {
return nil, nil
}
members := runtimePartyMembers(state)
if len(members) <= 1 {
return nil, nil
}
plan := runtimePartyDungeonEntryPlan{
dungeonID: leaderRuntime.Dungeon.ID,
mazeIndex: leaderRuntime.MazeIndex,
room: leaderScene.Coordinate,
mapID: leaderScene.Map.Map.ID,
boss: leaderRuntime.BossCoordinate,
seed: leaderRuntime.Seed,
maps: make(map[worldmap.RoomCoordinate]int64),
}
for _, room := range leaderRuntime.Session.Rooms() {
if room.Map != nil {
plan.maps[room.Coordinate] = room.Map.Map.ID
}
}
prepared := make([]*runtimePartyPreparedDungeonEntry, 0, len(members)-1)
for _, member := range members {
if member.UserID == 0 || member.UserID == leader.selectedCharacterID {
continue
}
follower, ok := s.onlineGameSession(member.UserID)
if !ok || follower == leader {
return nil, fmt.Errorf("party member %d has no resident game session", member.UserID)
}
// Resolved after the session lookup on purpose: the co-presence test
// needs the follower's own session because opening the dungeon-selection
// page retires town presence, so the live roster is empty exactly here.
//
// The one request that cannot satisfy it is the in-run transfer: entering
// the gate removed every squad member from the town roster by design
// (leaveTownPresenceForDungeon), so requiring a shared town anchor would
// refuse the warp that is trying to keep the squad together. The relaxation
// is scoped to that caller and reported, never silent.
if coPresent, presenceReason := s.currentPartyEntryCoPresence(leader, follower); !coPresent {
if inRunTransfer {
s.logGameEvent(leader, "game-raid-in-run-transfer-copresence-relaxed",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.UserID,
"dungeon_id", request.DungeonID,
"town_co_presence_reason", presenceReason,
"reason", "the_squad_is_inside_the_raid_so_the_town_anchor_is_gone_by_design")
} else {
// Name the side that failed to resolve, not only the member being
// judged: the refusal below is answered by the client with silence,
// so this line is the whole diagnosis (see currentPartyEntryCoPresence).
leaderPresence, leaderResolved := s.currentTownActorAreaDetachPresence(leader)
followerPresence, followerResolved := s.currentTownActorAreaDetachPresence(follower)
s.logGameEvent(leader, "game-party-dungeon-entry-copresence-refused",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.UserID,
"channel_id", leader.channel.ID,
"member_channel_id", follower.channel.ID,
"leader_anchor_resolved", leaderResolved,
"leader_town_id", leaderPresence.TownID,
"leader_area_id", leaderPresence.AreaID,
"leader_instance_key", leaderPresence.InstanceKey,
"member_anchor_resolved", followerResolved,
"member_town_id", followerPresence.TownID,
"member_area_id", followerPresence.AreaID,
"member_instance_key", followerPresence.InstanceKey,
"reason", presenceReason,
"client_visible_behaviour", "op16_retryable_rejection_reason_3_without_any_notice")
// The refusal is answered by the client with silence, so it is also
// recorded as the raid's own entry_blocked row: this is the one
// server-side event that explains a party that cannot re-enter a
// gate after a return.
s.recordRaidEvent(leader, "entry_blocked", map[string]any{
"party_id": state.PartyID,
"dungeon_id": request.DungeonID,
"actor_char_id": leader.selectedCharacterID,
"blocked_member_id": member.UserID,
"channel_id": leader.channel.ID,
"member_channel_id": follower.channel.ID,
"leader_anchor_resolved": leaderResolved,
"member_anchor_resolved": followerResolved,
"leader_town_id": leaderPresence.TownID,
"leader_area_id": leaderPresence.AreaID,
"member_town_id": followerPresence.TownID,
"member_area_id": followerPresence.AreaID,
"result": "refused",
"reason": presenceReason,
"client_visible": "op16_retryable_rejection_reason_3_without_any_notice",
})
return nil, fmt.Errorf("party member %d is not present in the leader town area: %s", member.UserID, presenceReason)
}
}
followerState := runtimePartyStateSnapshot(follower)
if followerState.PartyID != state.PartyID || followerState.UserID != state.UserID {
return nil, fmt.Errorf("party member %d has stale party state", member.UserID)
}
if ready, reason := s.currentTownEnterSelectReady(follower); !ready {
// The one relaxation, scoped to 再次挑战 and to one member state: the member
// is still on its own completed run's settlement page, because only the
// leader's client offers 再次挑战 and the member is driven by this request.
// Every other "not ready" state keeps the old refusal, so the 选择其他地下城
// route (where each client acts for itself) and every area/channel refusal
// are untouched.
//
// The in-run transfer is the second, equally narrow case: the member is
// standing in a live run of the gate rather than on a selector page, so
// there is no page to be ready. It is reported the same way.
switch {
case inRunTransfer:
s.logGameEvent(follower, "game-raid-in-run-transfer-selector-page-relaxed",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.UserID,
"dungeon_id", request.DungeonID,
"member_selector_reason", reason,
"reason", "the_member_is_inside_the_gate_not_on_a_selector_page")
case retryFromSettlement && s.settlementRetryDrivesMemberFromItsCompletedRun(follower, member.UserID, request):
s.logGameEvent(leader, "game-party-dungeon-retry-member-driven-by-server",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.UserID,
"dungeon_id", request.DungeonID,
"member_selector_reason", reason,
"reason", "only_the_leader_owns_retry_so_the_team_is_driven_by_this_request")
default:
return nil, fmt.Errorf("party member %d selector is not ready: %s", member.UserID, reason)
}
}
followerRuntime, followerScene, err := s.prepareDungeonRuntimePlanned(ctx, follower, request, &plan)
if err != nil {
return nil, fmt.Errorf("prepare party member %d for dungeon %d: %w", member.UserID, request.DungeonID, err)
}
memberIndex, memberIndexed := runtimePartyMemberIndex(member.UserID, state)
followerRuntime.partyMemberIndex, followerRuntime.partyMemberIndexed = memberIndex, memberIndexed
if err := applyRuntimePartyDungeonEntryPlan(followerRuntime, followerScene, plan); err != nil {
return nil, fmt.Errorf("apply leader dungeon selection to party member %d: %w", member.UserID, err)
}
if err := freezeCurrentDungeonTownReturnOrigin(follower, followerRuntime); err != nil {
return nil, fmt.Errorf("freeze party member %d town return origin: %w", member.UserID, err)
}
if _, err := buildCurrentDungeonEntryPackets(followerRuntime, followerScene); err != nil {
return nil, fmt.Errorf("build party member %d dungeon entry packets: %w", member.UserID, err)
}
prepared = append(prepared, &runtimePartyPreparedDungeonEntry{
session: follower,
runtime: followerRuntime,
scene: followerScene,
plan: plan,
})
s.logGameEvent(follower, "game-party-dungeon-entry-member-prepared",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.UserID,
"dungeon_id", followerRuntime.Dungeon.ID,
"maze_index", followerRuntime.MazeIndex,
"room", followerScene.Coordinate.String(),
"map_id", followerScene.Map.Map.ID,
"seed", followerRuntime.Seed)
}
// The selector page is only ever prepared for a member that is about to walk
// through one. A squadmate being carried inside a raid is standing in a live run,
// so there is no page to open and prepareRuntimePartyFollowerEnterSelect would
// only report "selector is not ready" for a member that is exactly where it
// should be. Its op5/op27/op28/op29 stream is driven by the entry itself, the
// same way a 再次挑战 member with no page is driven.
if inRunTransfer {
return prepared, nil
}
// 并发预检。这里原本是「逐个成员投到它的 event loop 再同步等」:7 个队友
// 串行累加,就是队长点「进入副本」之后在选择副本界面卡住的那几秒(现场
// 2026-09-26 18:09,op16 SELECT_DUNGEON 1770ms / 1209ms,紧接着
// op666 START_RAID 4218ms)。与 enterRuntimePartyFollowers 及另外四处扇出
// 保持同一套做法:总耗时从 Σ 变成 max,整轮共用一个总预算。
//
// 就绪门仍然按 prepared 的原顺序逐个判定,且第一个不合格就整体中止 —— 报错
// 文案与顺序都不变;区别只在于判定那一刻,每个成员都已经各自跑过预检。
readyByIndex := make([]bool, len(prepared))
indexBySession := make(map[*gameSession]int, len(prepared))
targets := make([]sessionFanoutTarget, 0, len(prepared))
for index, member := range prepared {
indexBySession[member.session] = index
targets = append(targets, sessionFanoutTarget{
label: fmt.Sprintf("%d", member.session.selectedCharacterID),
characterID: member.session.selectedCharacterID,
session: member.session,
})
}
if err := s.fanOutToGameSessions(ctx, targets, "runtime-party-dungeon-selector-preflight",
func(target sessionFanoutTarget) error {
index, known := indexBySession[target.session]
if !known {
return errors.New("party selector preflight target lost its prepared state")
}
var prepareErr error
readyByIndex[index], prepareErr = s.prepareRuntimePartyFollowerEnterSelect(
target.session,
leader.selectedCharacterID,
state.PartyID,
"leader_op16_party_follower_preflight",
)
return prepareErr
}); err != nil {
return nil, fmt.Errorf("prepare party selector preflight: %w", err)
}
for index, member := range prepared {
ready := readyByIndex[index]
if !ready {
// The second gate with the same condition as the readiness check above, and
// the same scoped relaxation: on 再次挑战 a member that is still on its own
// completed run's settlement page has no selector page to prepare, because
// only the leader's client can start the retry. The fan-out below drives it
// regardless (commitRuntimePartyPreparedFollowers +
// enterRuntimePartyFollowers), which is 86JP's PARTY_LOADING_GATE shape:
// push the entry to the member and wait for its FINISH_LOADING.
if !retryFromSettlement ||
!s.settlementRetryDrivesMemberFromItsCompletedRun(member.session, member.session.selectedCharacterID, request) {
return nil, fmt.Errorf("party member %d selector is not ready", member.session.selectedCharacterID)
}
s.logGameEvent(member.session, "game-party-dungeon-retry-member-page-skipped",
"party_id", state.PartyID,
"leader_char_id", leader.selectedCharacterID,
"char_id", member.session.selectedCharacterID,
"dungeon_id", request.DungeonID,
"reason", "the_member_is_on_its_own_completed_run_and_only_the_leader_owns_retry")
}
}
return prepared, nil
}
func applyRuntimePartyDungeonEntryPlan(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
plan runtimePartyDungeonEntryPlan,
) error {
if runtime == nil || runtime.Session == nil {
return errors.New("party member runtime is unavailable")
}
if runtime.Dungeon.ID != plan.dungeonID || runtime.MazeIndex != plan.mazeIndex ||
scene.Coordinate.X != plan.room.X || scene.Coordinate.Y != plan.room.Y ||
scene.Map.Map.ID != plan.mapID ||
runtime.BossCoordinate.X != plan.boss.X || runtime.BossCoordinate.Y != plan.boss.Y {
return fmt.Errorf(
"topology mismatch got dungeon=%d maze=%d room=%s map=%d boss=%s want dungeon=%d maze=%d room=%s map=%d boss=%s",
runtime.Dungeon.ID, runtime.MazeIndex, scene.Coordinate.String(), scene.Map.Map.ID, runtime.BossCoordinate.String(),
plan.dungeonID, plan.mazeIndex, plan.room.String(), plan.mapID, plan.boss.String(),
)
}
resolved := runtime.Session.Rooms()
if len(resolved) != len(plan.maps) {
return fmt.Errorf("resolved room count mismatch got=%d want=%d", len(resolved), len(plan.maps))
}
for _, room := range resolved {
wantMapID, ok := plan.maps[room.Coordinate]
if !ok || room.Map == nil || room.Map.Map.ID != wantMapID {
gotMapID := int64(0)
if room.Map != nil {
gotMapID = room.Map.Map.ID
}
return fmt.Errorf("room %s map mismatch got=%d want=%d known=%t", room.Coordinate, gotMapID, wantMapID, ok)
}
}
runtime.Seed = plan.seed
if visit := runtime.Rooms[runtimeDungeonRoomKeyFromScene(scene)]; visit != nil {
visit.Seed = plan.seed
visit.DropRNG = plan.seed
}
return nil
}
func (s *Service) commitRuntimePartyPreparedFollowers(prepared []*runtimePartyPreparedDungeonEntry) error {
committed := make([]*runtimePartyPreparedDungeonEntry, 0, len(prepared))
for _, member := range prepared {
if member == nil || member.session == nil || member.runtime == nil {
s.rollbackRuntimePartyPreparedFollowers(committed)
return errors.New("prepared party member runtime is incomplete")
}
if err := s.commitDungeonRuntime(member.session, member.runtime); err != nil {
s.rollbackRuntimePartyPreparedFollowers(committed)
return fmt.Errorf("commit party member %d runtime: %w", member.session.selectedCharacterID, err)
}
member.committed = true
committed = append(committed, member)
}
return nil
}
func (s *Service) rollbackRuntimePartyPreparedFollowers(prepared []*runtimePartyPreparedDungeonEntry) {
for _, member := range prepared {
if member == nil || member.session == nil || member.runtime == nil || !member.committed {
continue
}
member.session.dungeon.mu.Lock()
if member.session.dungeon.runtime == member.runtime {
if err := s.notifyRuntimeRaidDungeonAbandonedLocked(member.session, member.runtime); err != nil {
s.logGameEvent(member.session, "game-party-runtime-rollback-raid-abandon-publish-failed", "error", err)
}
s.retireCurrentDungeonRuntimeLocked(member.session, member.runtime, "party_runtime_rollback")
}
member.session.dungeon.mu.Unlock()
member.committed = false
}
}
func runtimePartyMemberIndexForSession(session *gameSession) (byte, bool) {
if session == nil || session.selectedCharacterID == 0 {
return 0, false
}
return runtimePartyMemberIndex(session.selectedCharacterID, runtimePartyStateSnapshot(session))
}
func runtimePartyMemberIndex(characterID uint16, state alignedcmd.PartyState) (byte, bool) {
members := runtimePartyMembers(state)
if len(members) <= 1 {
return 0, false
}
for _, member := range members {
if member.UserID == characterID && member.Slot < 8 {
return member.Slot, true
}
}
return 0, false
}
func currentDungeonRuntimePartyMemberIndex(runtime *runtimeDungeonState) byte {
if runtime == nil || runtime.partyDungeonOwner != nil || !runtime.partyMemberIndexed {
return 0xff
}
return runtime.partyMemberIndex
}
func (s *Service) enterRuntimePartyFollowers(
leader *gameSession,
body []byte,
prepared []*runtimePartyPreparedDungeonEntry,
state alignedcmd.PartyState,
retryFromSettlement bool,
) error {
if leader == nil || leader.selectedCharacterID == 0 {
return nil
}
if state.PartyID <= 0 || state.UserID != leader.selectedCharacterID {
return nil
}
if len(prepared) == 0 {
return nil
}
// 校验阶段仍然整体前置:任何一个成员不合法,就在动手之前中止,避免
// 只进了一半。校验通过后再并发执行。
targets := make([]sessionFanoutTarget, 0, len(prepared))
bySession := make(map[*gameSession]*runtimePartyPreparedDungeonEntry, len(prepared))
for _, member := range prepared {
if member == nil || member.session == nil || member.runtime == nil || !member.committed {
return errors.New("party follower entry was not prepared and committed")
}
follower := member.session
followerParty := runtimePartyStateSnapshot(follower)
if followerParty.PartyID != state.PartyID || followerParty.UserID != state.UserID {
return fmt.Errorf("party member %d changed party state during dungeon entry", follower.selectedCharacterID)
}
targets = append(targets, sessionFanoutTarget{
label: fmt.Sprintf("%d", follower.selectedCharacterID),
characterID: follower.selectedCharacterID,
session: follower,
})
bySession[follower] = member
}
// 并发扇出。这里原本是「逐个成员现开一个 createWriteTimeout 再同步等」:
// 8 人团最坏就是 3s x 7 = 21s 的串行累加,而那整段时间压在队长自己的
// event loop 上 —— 进图是攻坚里最重的一步,队长一卡,全队跟着等。
// 与 runtime-party-enter-select、party-dungeon-loading-release、
// runtime-party-dungeon-room-transition、runtime-party-selector-return
// 四处保持同一套做法:总耗时从 Σ 变成 max,整轮共用一个总预算。
return s.fanOutToGameSessions(context.Background(), targets, "runtime-party-dungeon-entry",
func(target sessionFanoutTarget) error {
member := bySession[target.session]
if member == nil {
return errors.New("party follower entry target lost its prepared state")
}
return s.handleDungeonSelectUpperPlanned(target.session, append([]byte(nil), body...), member, false, retryFromSettlement)
})
}
func (s *Service) leaveTownPresenceForDungeon(session *gameSession) {
if s == nil || s.onlinePlayers == nil || session == nil || session.selectedCharacterID == 0 {
return
}
// Capture the authoritative party before removing the town presence. A
// shared dungeon admission deliberately reuses the native party actors that
// already exist in town. Current NoPack class0/op6 is not merely a visual
// town leave: it also clears the matching party-member slot. If the follower
// leaves the directory first and that ordinary op6 reaches the still-loading
// leader, only the leader loses the follower and enters with a one-member
// room while the follower keeps both actors. Preserve members of the same
// admitted party; ordinary town observers must still retire the actor.
partyState := runtimePartyStateSnapshot(session)
// Entering the dungeon consumes a selector retirement rather than undoing
// it: the character is supposed to stay out of the town roster, and the
// memo must not survive to be replayed by a later selector return.
clearTownSelectorRetiredPresence(session)
departing, hadPresence := s.onlinePlayers.PlayerForCharacter(session.selectedCharacterID)
_, others, removed := s.onlinePlayers.LeaveAreaSession(session.selectedCharacterID, session)
if removed && len(others) > 0 {
s.broadcastTownPlayerDungeonTransition(session.selectedCharacterID, others, partyState)
}
if removed && hadPresence {
// A dungeon transition keeps the character online but changes the friend
// list's three-state channel marker from town-online to busy. Do not
// create a friend scene actor here; ordinary op6 already owns visual
// retirement for same-area observers.
s.notifyCurrentUnitedFriendPresence(
departing.Name,
currentFriendPresence{ChannelID: uint16(session.channel.ID), Busy: true},
"dungeon_entry_friend_busy",
)
}
}
// broadcastTownPlayerDungeonTransition is the online-player-manager analogue
// of the same-version reference's leaveForPartyDungeon event. Party peers keep
// the established actor/slot through op2/op11/op800/op29; non-party observers
// receive the normal op6 retirement.
func (s *Service) broadcastTownPlayerDungeonTransition(
characterID uint16,
others []onlinePlayerInfo,
state alignedcmd.PartyState,
) {
if s == nil || characterID == 0 || len(others) == 0 {
return
}
partyMembers := make(map[uint16]struct{}, len(runtimePartyMembers(state)))
if state.PartyID > 0 {
for _, member := range runtimePartyMembers(state) {
if member.UserID != 0 && member.UserID != characterID {
partyMembers[member.UserID] = struct{}{}
}
}
}
leaveBody := buildTownUserLeaveBody(characterID)
for i := range others {
peer := others[i].Session
if peer == nil {
continue
}
_, listedPartyPeer := partyMembers[others[i].CharacterID]
peerState := runtimePartyStateSnapshot(peer)
if listedPartyPeer && peerState.PartyID == state.PartyID && state.PartyID > 0 {
s.logGameEvent(peer, "game-town-party-peer-preserved-for-dungeon",
"party_id", state.PartyID,
"departing_char_id", characterID,
"peer_char_id", others[i].CharacterID,
"transport", "preserve-party-actor-until-dungeon-prelude")
continue
}
_ = s.sendCurrentSceneFixedClass0Packet(
peer,
currentTownUserLeaveMsgID,
leaveBody,
"town_copresence_party_dungeon_transition_nonparty_0x0006",
)
}
}
func (s *Service) replayDungeonSelectAckForActiveRuntime(
session *gameSession,
request dungeoncmd.SelectDungeonRequest,
requestBodyLen int,
) (bool, error) {
if session == nil {
return false, nil
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Session == nil {
session.dungeon.mu.Unlock()
return false, nil
}
activeRequest := runtime.Request
snapshot := runtime.Session.Snapshot().Run
session.dungeon.mu.Unlock()
if snapshot.Status != worldmap.DungeonRunActive ||
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) ||
!sameSelectDungeonRequest(activeRequest, request) {
return false, nil
}
if isCurrentDirectDungeonEntryRequest(request) {
s.logGameEvent(session, "game-dungeon-direct-entry-duplicate-suppressed",
"char_id", session.selectedCharacterID,
"dungeon_id", snapshot.DungeonID,
"maze_index", snapshot.MazeIndex,
"room", snapshot.Current.String(),
"request_body_len", requestBodyLen,
"reason", "direct_dungeon_entry_has_no_op16_ack_replay")
return true, nil
}
selectAckBody := buildSelectDungeonBodyForCharacter(session.selectedCharacterID)
s.logGameEvent(session, "game-dungeon-select-duplicate-ack-send",
"msg_id", uint16(dnfenum.CmdPacketSelectDungeon),
"body_len", len(selectAckBody)+1,
"mode", selectAckBody[0],
"reader_key", binary.LittleEndian.Uint16(selectAckBody[1:3]),
"request_body_len", requestBodyLen,
"dungeon_id", snapshot.DungeonID,
"maze_index", snapshot.MazeIndex,
"room", snapshot.Current.String(),
"replayed_packets", 0,
"reason", "idempotent_retry_for_same_active_current_dungeon")
if err := s.sendGameUpperSuccess(
session,
uint16(dnfenum.CmdPacketSelectDungeon),
selectAckBody,
); err != nil {
return true, err
}
return true, nil
}
func (s *Service) sendSelectDungeonFailure(session *gameSession, reason byte) error {
// Current EXE op16 reads failure, reason, and a separate supplemental byte.
return s.sendGameUpperRaw(session, uint16(dnfenum.CmdPacketSelectDungeon), buildSelectDungeonFailureBody(reason))
}
func buildSelectDungeonFailureBody(reason byte) []byte {
return []byte{0, reason, 0}
}
func currentDungeonAdmissionCounterFailureReason(err error) byte {
var limitErr *dnfdungeonadmission.LimitError
if errors.As(err, &limitErr) {
return currentSelectDungeonDailyEntryLimitReachedReason
}
return currentSelectDungeonPreparationRejectedReason
}
func sameSelectDungeonRequest(left, right dungeoncmd.SelectDungeonRequest) bool {
return dnfdungeon.SameSelectRequest(left, right)
}
func (s *Service) handleDungeonMoveMap(session *gameSession, body []byte) error {
if session != nil {
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
var owner *runtimePartyDungeonOwner
if runtime != nil {
owner = runtime.partyDungeonOwner
}
session.dungeon.mu.Unlock()
if owner != nil && owner.leaderID != session.selectedCharacterID {
s.logGameEvent(session, "game-party-dungeon-move-non-leader-consumed",
"party_id", owner.partyID,
"leader_char_id", owner.leaderID,
"char_id", session.selectedCharacterID,
"reason", "shared_room_transition_is_leader_owned")
return nil
}
if owner != nil {
owner.worldMu.Lock()
sharedNextObjectKey, sharedNextErr := s.runtimePartyDungeonNextObjectKey(owner)
if sharedNextErr != nil {
owner.worldMu.Unlock()
return fmt.Errorf("reserve party dungeon room object keys: %w", sharedNextErr)
}
before := runtimeDungeonRoomKey{}
beforeKnown := false
if runtime != nil && runtime.Session != nil {
if scene, ok := runtime.Session.Scene(); ok {
before, beforeKnown = runtimeDungeonRoomKeyFromScene(scene), true
}
}
err := s.handleDungeonMoveMapPlanned(
session,
body,
&runtimePartyDungeonMovePlan{firstObjectKey: sharedNextObjectKey},
false,
)
after := runtimeDungeonRoomKey{}
afterKnown := false
afterSeed := uint32(0)
session.dungeon.mu.Lock()
runtime = session.dungeon.runtime
if runtime != nil && runtime.Session != nil {
if scene, ok := runtime.Session.Scene(); ok {
after, afterKnown = runtimeDungeonRoomKeyFromScene(scene), true
afterSeed = runtime.Seed
}
}
session.dungeon.mu.Unlock()
if err == nil && beforeKnown && afterKnown && before != after {
owner.beginRoomLoading()
}
owner.worldMu.Unlock()
if err != nil || !beforeKnown || !afterKnown || before == after {
return err
}
// The shared transition is committed before any peer mailbox is
// entered. Never wait for another member while holding worldMu: a
// simultaneous op39 in that mailbox may need the same lock.
return s.moveRuntimePartyFollowers(
session,
owner,
body,
runtimePartyDungeonMovePlan{
target: after, targetSet: true, seed: afterSeed,
firstObjectKey: sharedNextObjectKey,
},
)
}
}
return s.handleDungeonMoveMapPlanned(session, body, nil, true)
}
type runtimePartyDungeonMovePlan struct {
target runtimeDungeonRoomKey
targetSet bool
seed uint32
firstObjectKey uint32
}
func (s *Service) handleDungeonMoveMapPlanned(
session *gameSession,
body []byte,
partyPlan *runtimePartyDungeonMovePlan,
fanoutParty bool,
) error {
if len(body) != dungeoncmd.MoveMapRequestSize {
s.logGameEvent(session, "game-dungeon-move-blocked",
"body_len", len(body),
"expected_body_len", dungeoncmd.MoveMapRequestSize,
"expected_layout", "current_exe_op45_plaintext_after_client_cipher_bypass",
"reason", "current_exe_op45_plaintext_request_boundary_mismatch")
return nil
}
request, err := dungeoncmd.DecodeMoveMapRequest(body)
if err != nil {
s.logGameEvent(session, "game-dungeon-move-blocked",
"body_len", len(body),
"reason", "current_exe_op45_request_malformed",
"error", err)
return nil
}
if session == nil {
return nil
}
session.dungeon.mu.Lock()
before := runtimeDungeonRoomKey{}
beforeKnown := false
if session.dungeon.runtime != nil && session.dungeon.runtime.Session != nil {
if scene, ok := session.dungeon.runtime.Session.Scene(); ok {
before, beforeKnown = runtimeDungeonRoomKeyFromScene(scene), true
}
}
err = s.handleDungeonMoveRequestLocked(session, request, len(body), "client_op45", partyPlan)
after := runtimeDungeonRoomKey{}
afterKnown := false
afterSeed := uint32(0)
var partyOwner *runtimePartyDungeonOwner
if session.dungeon.runtime != nil && session.dungeon.runtime.Session != nil {
if scene, ok := session.dungeon.runtime.Session.Scene(); ok {
after, afterKnown = runtimeDungeonRoomKeyFromScene(scene), true
afterSeed = session.dungeon.runtime.Seed
}
partyOwner = session.dungeon.runtime.partyDungeonOwner
}
session.dungeon.mu.Unlock()
if err != nil {
return err
}
if fanoutParty && beforeKnown && afterKnown && before != after {
if partyOwner != nil {
partyOwner.beginRoomLoading()
}
if err := s.moveRuntimePartyFollowers(session, partyOwner, body, runtimePartyDungeonMovePlan{target: after, seed: afterSeed}); err != nil {
return err
}
}
return nil
}
// currentDungeonSameCoordinateResyncOwnedByLayer reports whether a
// same-coordinate CHANGE MAP at this room belongs to the explicit layer
// machinery instead of the ordinary-room resync. An active layer or a
// PVF-declared layer at the coordinate requires MoveKind=1 to advance, and the
// deliberate refusal there ("preventing a player from skipping a pending story
// layer") must stay byte-for-byte as it was: those requests are not re-answered
// with a current-room payload.
func (s *Service) currentDungeonSameCoordinateResyncOwnedByLayer(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
) bool {
if runtime == nil {
return true
}
if runtime.LayeredMapActive {
return true
}
if runtime.LayerChains[scene.Coordinate] != nil {
return true
}
return currentDungeonCoordinateDeclaresLayer(runtime, scene.Coordinate)
}
// resyncCurrentDungeonSameCoordinateRoom answers a CHANGE MAP request whose
// target is the room the player is already standing in. It re-sends the cached
// START_MAP for the current room so the client's room-change handshake always
// completes, and deliberately leaves every piece of dungeon runtime state
// untouched: the coordinate, seed, room owner, object-key watermark, drops,
// fatigue and layer chains are already committed for this room, and re-running
// them would double-charge fatigue and re-roll drops.
//
// This mirrors the reference server, which accepts a requested coordinate that
// is any known room of the maze and replies with START_MAP for that room. The
// alternative -- failing closed with no packet at all -- is what froze the live
// client, because a dropped CHANGE MAP leaves it waiting forever.
func (s *Service) resyncCurrentDungeonSameCoordinateRoom(
session *gameSession,
runtime *runtimeDungeonState,
request dungeoncmd.MoveMapRequest,
requestSource string,
currentScene worldmap.DungeonRoomScene,
) error {
visit, err := runtime.currentDungeonRoomVisit(currentScene)
if err != nil {
return fmt.Errorf("resync same-coordinate dungeon room: %w", err)
}
plan := currentDungeonMovePlan{
Source: currentScene,
Target: currentScene,
SourceVisit: visit,
TargetVisit: visit,
TargetRoom: visit.Room,
NextObjectKey: runtime.NextObjectKey,
Seed: visit.Seed,
Revisit: true,
LayerIndex: -1,
Operation: currentDungeonStartMapOperationCurrent,
PayloadMode: currentDungeonStartMapPayloadCached,
}
startMapBody, err := buildCurrentDungeonMoveStartMapBody(runtime, plan)
if err != nil {
return fmt.Errorf("build same-coordinate dungeon room resync: %w", err)
}
if err := s.sendGameUpperRawClass(
session,
currentDungeonStartMapNotificationForRuntime(runtime),
startMapBody,
0,
); err != nil {
return err
}
// The client answers this START_MAP with FINISH_LOADING, and that callback's
// class0 state and op30 completion are room-scoped. Re-arm them or the
// reload would stall on a second, already-consumed handshake.
session.currentFinishLoadingStateSent = false
session.currentFinishLoadingCompletionSent = false
s.logGameEvent(session, "game-dungeon-move-same-room-resync",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", currentScene.Coordinate.String(),
"map_id", currentScene.Map.Map.ID,
"next_x", request.NextX,
"next_y", request.NextY,
"move_kind", request.MoveKind,
"request_source", requestSource,
"body_len", len(startMapBody),
"operation", plan.Operation,
"payload_mode", plan.PayloadMode,
"reason", "current_exe_same_coordinate_change_map_is_a_known_room_of_this_maze",
"state_mutation", "none",
"sequence", "current_exe_op45_same_coordinate_request_to_class0_op29_start_map")
return nil
}
func (s *Service) moveRuntimePartyFollowers(
leader *gameSession,
owner *runtimePartyDungeonOwner,
body []byte,
plan runtimePartyDungeonMovePlan,
) error {
if leader == nil || leader.selectedCharacterID == 0 {
return nil
}
if owner == nil || owner.leaderID != leader.selectedCharacterID {
return nil
}
// 并发扇出:每个 follower 都在【它自己的】event loop 上执行。调用方已经先
// 释放了 owner.worldMu(见 handleDungeonMoveRequestLocked 里
// "Never wait for another member while holding worldMu" 那条注释),
// 所以这里的并发不会与调用方互相等锁。
//
// 改前是在循环里给每人现开一个 createWriteTimeout 再同步等,把队长的
// event loop 按成员数串行累加地卡住。
targets := make([]sessionFanoutTarget, 0, len(owner.roster()))
for _, member := range owner.roster() {
if member.userID == leader.selectedCharacterID && member.generation == leader.characterGeneration {
continue
}
identity, ok := s.onlineGameSessionCharacterSnapshot(member.userID)
if !ok || identity.session == nil || identity.session == leader || identity.generation != member.generation {
return fmt.Errorf("party member %d has no current game session during room transition", member.userID)
}
targets = append(targets, sessionFanoutTarget{
label: fmt.Sprintf("%d", member.userID),
characterID: member.userID,
session: identity.session,
})
}
return s.fanOutToGameSessions(context.Background(), targets, "runtime-party-dungeon-room-transition",
func(target sessionFanoutTarget) error {
target.session.dungeon.mu.Lock()
peerRuntime := target.session.dungeon.runtime
target.session.dungeon.mu.Unlock()
if peerRuntime == nil || peerRuntime.partyDungeonOwner != owner {
return errors.New("party dungeon room-transition peer owner changed")
}
owner.worldMu.Lock()
defer owner.worldMu.Unlock()
return s.handleDungeonMoveMapPlanned(target.session, append([]byte(nil), body...), &plan, false)
})
}
// handleDungeonMoveRequestLocked runs with session.dungeon.mu held. Every op45
// is handled exactly once: an uncleared-room request is rejected and never
// retained for a later unsolicited op29.
func (s *Service) handleDungeonMoveRequestLocked(
session *gameSession,
request dungeoncmd.MoveMapRequest,
requestBodyLen int,
requestSource string,
partyPlan *runtimePartyDungeonMovePlan,
) error {
runtime := session.dungeon.runtime
if runtime == nil || runtime.Session == nil || runtime.Room == nil {
s.logGameEvent(session, "game-dungeon-move-blocked",
"next_x", request.NextX,
"next_y", request.NextY,
"reason", "dungeon_runtime_missing")
return nil
}
planningRuntime := runtime
if partyPlan != nil && partyPlan.firstObjectKey != 0 && partyPlan.firstObjectKey != runtime.NextObjectKey {
// Private party drops are intentionally rolled and numbered per member,
// so their connection-local NextObjectKey values can diverge inside one
// room. Plan the next shared room from the party-wide high-water mark
// without mutating the live runtime during preflight. Every member then
// receives the same monster/object keys while private rewards remain local.
// An old-room reward can race the leader's already-committed transition;
// in that case the old scene is being retired and the reserved shared key
// still wins, rather than splitting the new room a second time.
planningCopy := *runtime
planningCopy.NextObjectKey = partyPlan.firstObjectKey
planningRuntime = &planningCopy
}
plan, err := s.planCurrentDungeonMove(planningRuntime, request)
if err != nil {
currentScene, hasCurrentScene := runtime.Session.Scene()
source := plan.Source.Coordinate.String()
if hasCurrentScene {
source = currentScene.Coordinate.String()
}
// The live client re-issues CHANGE MAP for the room it already occupies:
// a same-coordinate op45 that the plain adjacency validator rejects on
// its own (|dx|+|dy| == 0, not 1). The reference implementation accepts
// any coordinate that is a known room of the maze ("known room") and
// answers every accepted CHANGE MAP with START_MAP; this server used to
// drop the request silently, so the client waited for a room-change reply
// that never came and froze -- it could neither leave the room nor return
// to town. Live 2026-09-22 05:45:51Z, char 1, dungeon 7539 maze 3:
// current=2:1 target=2:1, one blocked request and zero server packets,
// after which the client only emitted orphan USE_SKILL and went silent.
//
// The room does not change, so nothing here may touch dungeon state:
// fatigue, drops, object keys and layer chains are all already committed
// for this room. Only the room payload and the FINISH_LOADING handshake
// are re-armed, exactly as an accepted ordinary move would.
if hasCurrentScene &&
errors.Is(err, worldmap.ErrRoomNotAdjacent) &&
int64(request.NextX) == currentScene.Coordinate.X &&
int64(request.NextY) == currentScene.Coordinate.Y &&
!s.currentDungeonSameCoordinateResyncOwnedByLayer(runtime, currentScene) {
if resyncErr := s.resyncCurrentDungeonSameCoordinateRoom(
session,
runtime,
request,
requestSource,
currentScene,
); resyncErr != nil {
return resyncErr
}
return nil
}
s.logGameEvent(session, "game-dungeon-move-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", source,
"next_x", request.NextX,
"next_y", request.NextY,
"request_source", requestSource,
"reason", "target_room_preflight_failed",
"error", err)
return nil
}
if partyPlan != nil && partyPlan.targetSet {
target := runtimeDungeonRoomKeyFromScene(plan.Target)
if target != partyPlan.target {
return fmt.Errorf("party dungeon room topology mismatch got=%+v want=%+v", target, partyPlan.target)
}
plan.Seed = partyPlan.seed
}
if runtime.altar != nil {
if altarErr := runtime.altar.previewBindRoom(plan.Target); altarErr != nil {
s.logGameEvent(session, "game-dungeon-altar-move-blocked",
"dungeon_id", runtime.Dungeon.ID,
"source", plan.Source.Coordinate.String(),
"target", plan.Target.Coordinate.String(),
"reason", "target_altar_room_definition_or_generation_invalid",
"error", altarErr)
return nil
}
}
if plan.PayloadMode != currentDungeonStartMapPayloadCached {
startDropRNG := currentPartyDungeonPrivateDropRNG(runtime, plan.Seed)
if plan.TargetVisit != nil {
startDropRNG = plan.TargetVisit.DropRNG
}
passivePlan, planErr := s.planCurrentDungeonPassiveObjectDrops(
session,
runtime,
plan.Target,
plan.NextObjectKey,
startDropRNG,
)
if planErr != nil {
s.logGameEvent(session, "game-dungeon-move-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", plan.Source.Coordinate.String(),
"target", plan.Target.Coordinate.String(),
"reason", "target_passive_object_drop_preflight_failed",
"error", planErr)
return nil
}
plan.PassiveDrops = passivePlan
}
// Moving away is authoritative evidence that the source room was cleared.
// Settle pet growth against the exact fatigue amount already committed for
// that room; the clear token makes retries and revisits idempotent.
if plan.SourceVisit != nil {
s.awardCurrentPetRoomClearLocked(
session,
runtime,
plan.Source,
int(plan.SourceVisit.FatigueConsumedAmount),
"room_transition_source_clear",
)
}
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
session,
runtime,
"accepted_current_exe_op45_after_pending_op24",
)
if plan.LayerBaseAck {
chain := runtime.LayerChains[plan.Source.Coordinate]
if chain == nil || !chain.Consumed || !chain.FinalAckPending {
return fmt.Errorf("%w: layered base ACK state changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
chain.FinalAckPending = false
s.logGameEvent(session, "game-dungeon-layer-base-ack-consumed",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", plan.Source.Coordinate.String(),
"map_id", plan.Source.Map.Map.ID,
"request_body_len", requestBodyLen,
"request_source", requestSource,
"response", "none",
"reason", "current_exe_final_same_coordinate_change_map_confirmation_after_operation2_mode0_base_restore")
return nil
}
startMapBody, err := buildCurrentDungeonMoveStartMapBody(runtime, plan)
if err != nil {
s.logGameEvent(session, "game-dungeon-move-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", plan.Source.Coordinate.String(),
"target", plan.Target.Coordinate.String(),
"reason", "target_start_map_build_failed",
"error", err)
return nil
}
randomizedCreatureQueue, randomizedCreatureCount, err := buildCurrentDungeonRandomizedCreatureQueue(
plan.Target.Coordinate,
runtime.RandomizedObjectSelections,
)
if err != nil {
s.logGameEvent(session, "game-dungeon-move-blocked",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", plan.Source.Coordinate.String(),
"target", plan.Target.Coordinate.String(),
"reason", "target_randomized_creature_queue_failed",
"error", err)
return nil
}
targetSnapshot := plan.TargetRoom.Snapshot()
packetActorCount := len(targetSnapshot.Monsters) + len(targetSnapshot.ExtendedActors)
if plan.PayloadMode == currentDungeonStartMapPayloadCached {
packetActorCount = 0
}
s.logGameEvent(session, "game-dungeon-move-start-map-send",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", plan.Source.Coordinate.String(),
"target", plan.Target.Coordinate.String(),
"map_id", plan.Target.Map.Map.ID,
"seed", plan.Seed,
"actor_count", packetActorCount,
"revisit", plan.Revisit,
"layered", plan.Layered,
"layer_return", plan.LayerReturn,
"layer_exit", plan.LayerExit,
"layer_base_ack", plan.LayerBaseAck,
"layer_index", plan.LayerIndex,
"story_stage", plan.StoryStage,
"story_stage_index", plan.StoryStageIndex,
"boss_entrance_map_override", plan.BossEntranceMapOverride,
"operation", plan.Operation,
"payload_mode", plan.PayloadMode,
"move_kind", request.MoveKind,
"request_body_len", requestBodyLen,
"request_source", requestSource,
"body_len", len(startMapBody),
"sequence", "current_exe_op45_request_to_class0_op29_start_map")
if plan.PayloadMode != currentDungeonStartMapPayloadCached && randomizedCreatureCount != 0 {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketVerifyCreatureQuest),
randomizedCreatureQueue,
0,
); err != nil {
return err
}
s.logGameEvent(session, "game-dungeon-randomized-neutral-creature-queued",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", plan.Target.Coordinate.String(),
"creature_count", randomizedCreatureCount,
"packet_order", "op479-explicit-creature-before-op29-room-transition")
}
if err := s.sendGameUpperRawClass(session, currentDungeonStartMapNotificationForRuntime(runtime), startMapBody, 0); err != nil {
return err
}
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
return fmt.Errorf("cache source dungeon room before transition commit: %w", err)
}
var revisitScene *worldmap.DungeonRoomScene
if plan.Revisit && !plan.StoryStage && !plan.BossEntranceMapOverride {
revisitScene = &plan.TargetVisit.Scene
}
var committedScene worldmap.DungeonRoomScene
if plan.BossEntranceMapOverride {
var cachedOverrideScene *worldmap.DungeonRoomScene
if plan.TargetVisit != nil {
cachedOverrideScene = &plan.TargetVisit.Scene
}
transition, commitErr := runtime.Session.CommitMoveMapOverride(
plan.Target.Coordinate,
plan.BossEntranceResolvedMap,
cachedOverrideScene,
)
if commitErr != nil {
return fmt.Errorf("commit boss-entrance map override after op29 send: %w", commitErr)
}
if transition.Revisit != plan.Revisit {
return fmt.Errorf("%w: boss-entrance override revisit changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
committedScene = transition.Scene
} else if plan.StoryStage {
transition, commitErr := runtime.Session.CommitStoryStage(plan.StoryStageIndex)
if commitErr != nil {
return fmt.Errorf("commit current dungeon story stage after op29 send: %w", commitErr)
}
if transition.Revisit != plan.Revisit {
return fmt.Errorf("%w: story-stage revisit changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
committedScene = transition.Scene
} else if plan.Layered {
committedScene, err = runtime.Session.CommitLayered(plan.Target.Coordinate, plan.LayerIndex)
if err != nil {
return fmt.Errorf("commit current layered dungeon room after op29 send: %w", err)
}
} else if plan.LayerReturn {
if plan.TargetVisit == nil {
return fmt.Errorf("%w: layered base visit missing at commit", errDungeonMoveRuntimeOwnerMismatch)
}
committedScene, err = runtime.Session.CommitLayeredBase(plan.Target.Coordinate, plan.TargetVisit.Scene)
if err != nil {
return fmt.Errorf("commit cached layered dungeon base after op29 send: %w", err)
}
} else if plan.LayerExit {
transition, moveErr := runtime.Session.MoveByteTransition(request.NextX, request.NextY, revisitScene)
if moveErr != nil {
return fmt.Errorf("commit terminal layered dungeon exit after op29 send: %w", moveErr)
}
committedScene = transition.Scene
} else {
transition, moveErr := runtime.Session.MoveByteTransition(request.NextX, request.NextY, revisitScene)
if moveErr != nil {
return fmt.Errorf("commit current dungeon room after op29 send: %w", moveErr)
}
committedScene = transition.Scene
}
if committedScene.Coordinate != plan.Target.Coordinate || committedScene.Map.Map.ID != plan.Target.Map.Map.ID {
return fmt.Errorf("%w: planned=%s/%d committed=%s/%d",
errDungeonMoveRuntimeOwnerMismatch,
plan.Target.Coordinate,
plan.Target.Map.Map.ID,
committedScene.Coordinate,
committedScene.Map.Map.ID,
)
}
s.keepCurrentDungeonDeathReturnAcrossRoomTransitionLocked(session, runtime)
runtime.Room = plan.TargetRoom
runtime.Seed = plan.Seed
if runtime.altar != nil {
s.cancelCurrentDungeonAltarTimersLocked(runtime)
if err := runtime.altar.bindRoom(committedScene); err != nil {
return fmt.Errorf("bind current altar target room after op211 send: %w", err)
}
}
if !plan.LayerReturn {
if _, err := s.applyPVFTutorialBasicActionRoomToSession(
session,
runtime,
committedScene,
"room_transition_before_actor_announce",
); err != nil {
return fmt.Errorf("apply tutorial basic-action target room: %w", err)
}
committedScene, _ = runtime.Session.Scene()
}
if plan.StoryStage {
if plan.StoryStageIndex != runtime.StoryStageIndex+1 {
return fmt.Errorf("%w: story-stage index changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
runtime.StoryStageIndex = plan.StoryStageIndex
runtime.LayeredMapIndex = -1
runtime.LayeredMapActive = false
} else if plan.Layered {
if runtime.LayerChains == nil {
runtime.LayerChains = make(map[worldmap.RoomCoordinate]*runtimeDungeonLayerChain)
}
chain := runtime.LayerChains[plan.Target.Coordinate]
if chain == nil {
chain = &runtimeDungeonLayerChain{BaseKey: plan.LayerBaseKey}
runtime.LayerChains[plan.Target.Coordinate] = chain
} else if chain.BaseKey != plan.LayerBaseKey {
return fmt.Errorf("%w: layered base key changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
chain.Consumed = false
chain.FinalAckPending = false
runtime.LayeredMapIndex = plan.LayerIndex
runtime.LayeredMapActive = true
} else if plan.LayerReturn {
chain := runtime.LayerChains[plan.Target.Coordinate]
if chain == nil || chain.BaseKey != plan.LayerBaseKey {
return fmt.Errorf("%w: layered return chain changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
chain.Consumed = true
chain.FinalAckPending = true
runtime.LayeredMapIndex = -1
runtime.LayeredMapActive = false
} else if plan.LayerExit {
chain := runtime.LayerChains[plan.Source.Coordinate]
if chain == nil || chain.BaseKey != plan.LayerBaseKey {
return fmt.Errorf("%w: terminal layered exit chain changed before commit", errDungeonMoveRuntimeOwnerMismatch)
}
chain.Consumed = true
chain.FinalAckPending = false
runtime.LayeredMapIndex = -1
runtime.LayeredMapActive = false
} else {
runtime.LayeredMapIndex = -1
runtime.LayeredMapActive = false
}
announcedActors := 0
if plan.PayloadMode != currentDungeonStartMapPayloadCached {
announcedActors, err = runtime.Room.AnnounceAllActors(runtime.Session)
if err != nil {
return fmt.Errorf("commit target-room runtime actors after op29 send: %w", err)
}
}
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
return fmt.Errorf("cache committed target dungeon room: %w", err)
}
if plan.PayloadMode != currentDungeonStartMapPayloadCached {
if err := commitCurrentDungeonPassiveObjectDrops(runtime, committedScene, plan.PassiveDrops); err != nil {
return fmt.Errorf("commit target-room passive-object drops after op29 send: %w", err)
}
}
if err := s.consumeCurrentDungeonRoomFatigueAfterCommit(session, runtime, "room_transition_after_op29_commit"); err != nil {
return fmt.Errorf("consume target-room fatigue: %w", err)
}
runtime.NextObjectKey = plan.NextObjectKey
if runtime.partyDungeonOwner != nil {
if _, worldErr := runtime.partyDungeonOwner.observeRuntimePartyDungeonWorldLocked(runtime); worldErr != nil {
return fmt.Errorf("commit party shared target room: %w", worldErr)
}
}
// FINISH_LOADING's main class0 state and op30 completion are room-scoped.
// Keep the player/equipment/skill initialization gates intact: an ordinary
// room move only needs the next room's loading completion, not a new actor.
session.currentFinishLoadingStateSent = false
session.currentFinishLoadingCompletionSent = false
s.logGameEvent(session, "game-dungeon-move-committed",
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"source", plan.Source.Coordinate.String(),
"target", committedScene.Coordinate.String(),
"map_id", committedScene.Map.Map.ID,
"announced_actor_count", announcedActors,
"revisit", plan.Revisit,
"layered", plan.Layered,
"layer_return", plan.LayerReturn,
"layer_exit", plan.LayerExit,
"layer_base_ack", plan.LayerBaseAck,
"layer_index", plan.LayerIndex,
"story_stage", plan.StoryStage,
"story_stage_index", plan.StoryStageIndex,
"boss_entrance_map_override", plan.BossEntranceMapOverride,
"operation", plan.Operation,
"payload_mode", plan.PayloadMode,
"cached_defeated_actor_count", len(committedScene.DefeatedObjects),
"passive_object_item_rows", plan.PassiveDrops.ConfiguredRowCount,
"passive_object_drop_count", len(plan.PassiveDrops.ExtraEntries),
"room_cleared", committedScene.Cleared,
"request_source", requestSource,
"finish_loading_main_state_rearmed", true)
// 卢克的 [ON ENTER MAP]:走到王座的 boss 房才开能源储藏室(150 秒团灭钟从这里起算)。
// 非卢克一律直接返回,不改动任何既有行为。见 raid_luke_phase_runtime.go。
s.observeRuntimeRaidLukeEnterMapLocked(session, runtime, committedScene.Map.Map.ID)
// 超时空的 [ON ENTER MAP]:同一个位置、同一个理由 —— 走到 BOSS 那张地图才把这一侧的
// BOSS 机制从 state 2 推到 5(四颗星球出场、伤害窗口的前置)。原来只有副本
// finish-loading 那一次触发、带的是起始房的地图 ID,于是 state 永远停在 2:
// 四个球一个都不出现、罗什不给打。见 notifyRuntimeFiendWarMapEnteredLocked 的注释。
// 非超时空、不是 BOSS 侧、或已经推进过(state != 2)时它一个字都不改。
s.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, committedScene.Map.Map.ID, "room_transition_after_op45_commit")
if plan.PayloadMode != currentDungeonStartMapPayloadCached {
if err := s.publishCurrentDungeonTrapEventMonsterAfterStartMap(
session,
runtime,
committedScene,
runtime.Room,
"room_transition_after_op29_commit",
); err != nil {
return fmt.Errorf("publish target-room trap event-monster condition after op29: %w", err)
}
}
// Runs for cached revisits too: a gate another room's kills opened since
// the last visit has to be published the moment the player walks back in.
// (上游 b402882)
s.runCurrentDungeonActionObjectsLocked(session, runtime, committedScene, runtimeDungeonActionObjectRoomEntered, "room_transition_after_op29_commit")
return nil
}