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