服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
407 lines
17 KiB
Go
407 lines
17 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
)
|
|
|
|
func (s *Service) sendEnterSelectDungeonState(
|
|
session *gameSession,
|
|
source string,
|
|
transport bool,
|
|
requestDriven bool,
|
|
) error {
|
|
backToVillagePending := false
|
|
if !transport && requestDriven && session != nil {
|
|
session.townMu.Lock()
|
|
session.townTransportEnterSelectPending = false
|
|
session.townMu.Unlock()
|
|
}
|
|
if !transport && requestDriven && session != nil && session.backToVillageEnterSelectPending {
|
|
// The current client reuses this first op15 both as the typed-op24 scene
|
|
// finalizer and as the player's selector intent. Keep the marker until the
|
|
// pending return has committed so a failed repository/player-state suffix
|
|
// can be retried without requiring a second click.
|
|
backToVillagePending = true
|
|
}
|
|
if requestDriven && isCurrentImplementedRaidChannel(session) {
|
|
state, allowed, reason := s.runtimeRaidSelectorAdmission(session)
|
|
if !allowed {
|
|
s.logGameEvent(session, "game-raid-enter-select-dungeon-blocked",
|
|
"source", source,
|
|
"char_id", selectedCharacterID(session),
|
|
"raid_key", state.RaidKey,
|
|
"raid_started", state.Started,
|
|
"entry_cost_paid", state.EntryCostPaid,
|
|
"lifecycle_status", byte(state.Lifecycle.Status),
|
|
"lifecycle_phase", state.Lifecycle.Phase,
|
|
"reason", reason,
|
|
"response", "none")
|
|
return nil
|
|
}
|
|
}
|
|
if !transport && requestDriven && session != nil && session.selectedCharacterID != 0 {
|
|
partyState := runtimePartyStateSnapshot(session)
|
|
if partyState.PartyID > 0 && len(runtimePartyMembers(partyState)) > 1 &&
|
|
partyState.UserID != session.selectedCharacterID {
|
|
// A non-leader still needs the return transaction committed and its
|
|
// live-dungeon membership removed. It does not own the shared selector,
|
|
// so consume the same first op15 without opening a second page.
|
|
if backToVillagePending {
|
|
if err := s.commitPendingDungeonReturnBeforeTownEnterSelect(session, source+"_party_member_return"); err != nil {
|
|
return err
|
|
}
|
|
session.backToVillageEnterSelectPending = false
|
|
session.backToVillageSelectorDuplicateGuard = true
|
|
if err := s.rebuildRuntimePartyTownCoPresenceAfterReturn(session, source+"_party_member_return"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-party-enter-select-non-leader-consumed",
|
|
"source", source,
|
|
"party_id", partyState.PartyID,
|
|
"leader_char_id", partyState.UserID,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "same_version_party_selector_is_leader_owned")
|
|
return nil
|
|
}
|
|
}
|
|
if !transport && requestDriven {
|
|
if err := s.commitPendingDungeonReturnBeforeTownEnterSelect(session, source); err != nil {
|
|
return err
|
|
}
|
|
if backToVillagePending {
|
|
session.backToVillageEnterSelectPending = false
|
|
session.backToVillageSelectorDuplicateGuard = true
|
|
if err := s.rebuildRuntimePartyTownCoPresenceAfterReturn(
|
|
session,
|
|
source+"_after_back_to_village_return",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-upper-enter-select-dungeon-after-back-to-village-accepted",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "current_exe_first_op15_is_scene_finalizer_and_selector_intent",
|
|
"response", "selector_state")
|
|
}
|
|
retired, err := s.retireCompletedDungeonForTownSelect(session)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-enter-select-dungeon-completed-runtime-retire-blocked",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "verified_town_origin_unavailable",
|
|
"error", err)
|
|
} else if retired {
|
|
s.logGameEvent(session, "game-upper-enter-select-dungeon-completed-runtime-retired",
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "settlement_page_select_other_dungeon_uses_current_op15_op27_route")
|
|
}
|
|
}
|
|
if requestDriven && session.enterSelectDungeonAckSent {
|
|
s.logGameEvent(session, "game-upper-enter-select-dungeon-duplicate-ack-replay",
|
|
"source", source,
|
|
"request_driven", true,
|
|
"packet_count", 2,
|
|
"reason", "current_client_retries_op15_after_post_start_map_state")
|
|
}
|
|
if !requestDriven && session.enterSelectDungeonSent {
|
|
s.logGameEvent(session, "game-upper-enter-select-dungeon-duplicate-skipped", "source", source, "request_driven", false)
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
|
|
|
|
contextReady := false
|
|
contextReadyReason := "legacy_or_proactive_path"
|
|
if !transport && requestDriven {
|
|
contextReady, contextReadyReason = s.currentTownEnterSelectReady(session)
|
|
}
|
|
packetCount := 2
|
|
if contextReady {
|
|
packetCount += 3
|
|
}
|
|
s.logPacketEvent("game-enter-select-dungeon-state-send",
|
|
"conn_id", session.connID,
|
|
"channel_id", session.channel.ID,
|
|
"source", source,
|
|
"char_id", charID,
|
|
"scene_object_key", currentSceneActorObjectKey(charID),
|
|
"transport", transport,
|
|
"request_driven", requestDriven,
|
|
"enter_msg_id", uint16(dnfenum.CmdPacketEnterSelectDungeon),
|
|
"fatigue_msg_id", currentFatigueMsgID,
|
|
"packet_count", packetCount,
|
|
"town_context_ready", contextReady,
|
|
"town_context_ready_reason", contextReadyReason,
|
|
"reason", "current_exe_op15_ack_op36_fatigue_optional_missing_skill_manager_op19_op5_permissions_and_guarded_op27_enter_select_page")
|
|
|
|
if transport {
|
|
enterBody := buildEnterSelectDungeonBody(charID)
|
|
if err := s.sendGame(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEnterSelectDungeon), enterBody); err != nil {
|
|
return err
|
|
}
|
|
} else if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEnterSelectDungeon), buildEnterSelectDungeonAckBody()); err != nil {
|
|
return err
|
|
}
|
|
fatigueBody := buildCurrentFatigueBody(character, hasCharacter)
|
|
s.logGameEvent(session, "game-enter-select-dungeon-fatigue-send",
|
|
"char_id", charID,
|
|
"msg_id", currentFatigueMsgID,
|
|
"body_len", len(fatigueBody),
|
|
"fatigue_used", binary.LittleEndian.Uint16(fatigueBody[0:2]),
|
|
"fatigue_limit", binary.LittleEndian.Uint16(fatigueBody[2:4]),
|
|
"actor_aux", binary.LittleEndian.Uint16(fatigueBody[4:6]),
|
|
"fatigue_battery_counter", binary.LittleEndian.Uint16(fatigueBody[6:8]),
|
|
"actor_extra", binary.LittleEndian.Uint16(fatigueBody[8:10]),
|
|
"body_source", "current_exe_sub_1D7ABE0_fatigue_five_u16")
|
|
if transport {
|
|
if err := s.sendGame(session, byte(dnfenum.GameCmdCommand), currentFatigueMsgID, fatigueBody); err != nil {
|
|
return err
|
|
}
|
|
} else if err := s.sendGameUpperRawClass(session, currentFatigueMsgID, fatigueBody, 0); err != nil {
|
|
return err
|
|
}
|
|
session.enterSelectDungeonSent = true
|
|
if requestDriven {
|
|
session.enterSelectDungeonAckSent = true
|
|
session.enterSelectDungeonPassiveParty = false
|
|
}
|
|
if !transport && requestDriven {
|
|
if contextReady {
|
|
// The current client evaluates its local skill-initialization guard
|
|
// before it sends op16. A town transition or another actor-manager
|
|
// rebuild can leave that guard stale even though op29 committed the
|
|
// learned levels and the repository is complete. Reinstall the full
|
|
// repository/PVF-backed op19 while the finalized town actor is still the
|
|
// current owner.
|
|
if !hasCharacter {
|
|
return fmt.Errorf("dungeon selector skill initialization requires selected character %d", charID)
|
|
}
|
|
if err := s.sendCurrentSceneSkillInfo(
|
|
session,
|
|
ctx,
|
|
character,
|
|
source+"_after_op15_ack_fatigue_before_op27",
|
|
); err != nil {
|
|
return fmt.Errorf("initialize dungeon selector skills: %w", err)
|
|
}
|
|
// A fallback op19 is character-specific and can rebuild native managers
|
|
// used by the selector. Publish the complete PVF ordinary-dungeon authority
|
|
// after that decision, as the final state before op27 binds the page. A valid
|
|
// character with zero active quests must still receive every ordinary
|
|
// dungeon row at state zero or higher; task state never gates this table.
|
|
if err := s.sendCurrentDungeonPermissionSnapshot(ctx, session, source+"_after_op15_ack_fatigue_and_skill_manager"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendCurrentTownEnterSelectContext(session, source+"_after_op15_ack_fatigue"); err != nil {
|
|
return err
|
|
}
|
|
if contextReady {
|
|
if err := s.synchronizeRuntimePartyMembersEnterSelect(session, source+"_party_members"); err != nil {
|
|
return err
|
|
}
|
|
// Operator product decision: a character sitting on the
|
|
// dungeon-selection page must no longer be visible in town. This runs
|
|
// after the party fan-out on purpose - every path that resolves the
|
|
// opener's followers or their town rows should still see the initiator
|
|
// in the roster while it does so.
|
|
//
|
|
// Deliberately NOT followed by the party co-presence projection here:
|
|
// town_enter_select_test.go pins this fan-out's shape, and it forbids the
|
|
// passive follower an op9 record (the actor projection embeds one). The
|
|
// route that needs the projection is the one this page is reached from
|
|
// after a completed run - see retireCompletedDungeonForTownSelect.
|
|
s.leaveTownPresenceForDungeonSelector(session, source+"_after_selector_page_committed")
|
|
}
|
|
}
|
|
return s.sendRuntimeRaidSelectorSnapshot(session, source+"_after_current_selector_context")
|
|
}
|
|
|
|
// runtimeRaidSelectorAdmission owns the Anton waiting-map dungeon portal.
|
|
// Type-23 is a cross-channel event boundary, so it must never fall back to the
|
|
// ordinary selector merely because the client sent the common op15 command.
|
|
func (s *Service) runtimeRaidSelectorAdmission(session *gameSession) (runtimeRaidState, bool, string) {
|
|
spec, raidChannel := currentRaidTownSpecForSession(session)
|
|
if s == nil || session == nil || !raidChannel {
|
|
return runtimeRaidState{}, true, "ordinary_channel"
|
|
}
|
|
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
|
|
if !ok {
|
|
return runtimeRaidState{}, false, "raid_membership_missing"
|
|
}
|
|
if !state.Started || !state.EntryCostPaid {
|
|
return state, false, "raid_not_started"
|
|
}
|
|
if state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
|
|
return state, false, "raid_phase_not_active"
|
|
}
|
|
if _, frozen := runtimeRaidFrozenMember(state, selectedCharacterID(session)); !frozen {
|
|
return state, false, "raid_member_not_in_frozen_roster"
|
|
}
|
|
if s.onlinePlayers == nil {
|
|
return state, false, "raid_waiting_instance_unavailable"
|
|
}
|
|
presence, present := s.onlinePlayers.PlayerForCharacter(selectedCharacterID(session))
|
|
if !present || presence.Session != session ||
|
|
presence.TownID != byte(spec.townID) ||
|
|
presence.AreaID != spec.waitingArea ||
|
|
presence.InstanceKey != state.RaidKey {
|
|
// Dungeon entry deliberately removes the actor from town presence. The
|
|
// settlement-page "select another dungeon" action is nevertheless a
|
|
// valid return to the Raid selector after the completed objective and
|
|
// the op72 exit ACK are both committed. Requiring the waiting-instance
|
|
// presence here deadlocks exactly that route: the actor cannot regain a
|
|
// town presence until after the completed dungeon runtime is retired.
|
|
// Keep this exception narrow so an arbitrary op15 from an active run (or
|
|
// an unjoined/unstarted Raid) still cannot cross the private portal.
|
|
if completedRuntimeRaidSelectorReturnReady(session, state.RaidKey) {
|
|
return state, true, "completed_raid_objective_exit"
|
|
}
|
|
// The corner-room wall_open warp out of an active energy-interception run
|
|
// (218) into a hatchery the raid has opened sends the same op15. Refusing
|
|
// it answered nothing at all (`response=none`), so the client was left
|
|
// without the selector page its op15 is the finalizer for: live
|
|
// 2026-09-17 07:25:12Z, dungeon_id=222, followed by
|
|
// game-dungeon-move-blocked reason=dungeon_runtime_missing and
|
|
// game-dungeon-giveup-game-blocked reason=active_dungeon_runtime_owner_missing
|
|
// -- a client that could neither move nor leave.
|
|
if s.runtimeRaidHatcheryWarpAdmission(session, state) {
|
|
return state, true, "hatchery_transfer_warp"
|
|
}
|
|
return state, false, "raid_member_not_in_private_waiting_instance"
|
|
}
|
|
return state, true, "raid_phase_active"
|
|
}
|
|
|
|
func completedRuntimeRaidSelectorReturnReady(session *gameSession, raidKey uint32) bool {
|
|
if session == nil || raidKey == 0 || session.selectedCharacterID == 0 {
|
|
return false
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
return runtime != nil && runtime.raidDungeon.RaidKey == raidKey &&
|
|
runtime.settlementCardExitAckSent &&
|
|
runtime.settlementPhase >= currentDungeonSettlementPhaseEnding &&
|
|
completedRuntimeRaidDungeonExitReady(runtime, session.selectedCharacterID)
|
|
}
|
|
|
|
// synchronizeRuntimePartyMembersEnterSelect promotes the leader's op15 to one
|
|
// party scene transition. Members may be in different public areas on the same
|
|
// channel; stable party membership, not town visibility, owns the fan-out.
|
|
func (s *Service) synchronizeRuntimePartyMembersEnterSelect(initiator *gameSession, source string) error {
|
|
if initiator == nil || initiator.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
state := runtimePartyStateSnapshot(initiator)
|
|
if state.PartyID <= 0 || state.UserID != initiator.selectedCharacterID {
|
|
return nil
|
|
}
|
|
// 并发扇出:initiator 自己已被跳过,每个 follower 都在【它自己的】event loop
|
|
// 上执行且互不依赖。改前是在循环里给每人现开一个 createWriteTimeout 再同步等,
|
|
// 把发起者的 event loop 按成员数串行累加地卡住。
|
|
members := runtimePartyMembers(state)
|
|
targets := make([]sessionFanoutTarget, 0, len(members))
|
|
for _, member := range members {
|
|
if member.UserID == 0 || member.UserID == initiator.selectedCharacterID {
|
|
continue
|
|
}
|
|
follower, ok := s.onlineGameSession(member.UserID)
|
|
if !ok || follower == initiator {
|
|
continue
|
|
}
|
|
followerState := runtimePartyStateSnapshot(follower)
|
|
if followerState.PartyID != state.PartyID || followerState.UserID != state.UserID {
|
|
continue
|
|
}
|
|
targets = append(targets, sessionFanoutTarget{
|
|
label: fmt.Sprintf("%d", member.UserID),
|
|
characterID: member.UserID,
|
|
session: follower,
|
|
})
|
|
}
|
|
return s.fanOutToGameSessions(context.Background(), targets, "runtime-party-enter-select",
|
|
func(target sessionFanoutTarget) error {
|
|
_, prepareErr := s.prepareRuntimePartyFollowerEnterSelect(
|
|
target.session,
|
|
state.UserID,
|
|
state.PartyID,
|
|
source,
|
|
)
|
|
return prepareErr
|
|
})
|
|
}
|
|
|
|
// prepareRuntimePartyFollowerEnterSelect uses the same-version passive-member
|
|
// selector sequence: own fatigue, leader-derived dungeon authority and op27.
|
|
// A follower did not submit op15, so it must not receive a fabricated op15 ACK
|
|
// or rebuild the established party table with op9.
|
|
func (s *Service) prepareRuntimePartyFollowerEnterSelect(
|
|
follower *gameSession,
|
|
leaderCharacterID uint16,
|
|
partyID int,
|
|
source string,
|
|
) (bool, error) {
|
|
if follower == nil || follower.selectedCharacterID == 0 {
|
|
return false, nil
|
|
}
|
|
if follower.enterSelectDungeonContextSent && follower.enterSelectDungeonSent {
|
|
return true, nil
|
|
}
|
|
ready, reason := s.currentTownEnterSelectReady(follower)
|
|
if !ready {
|
|
s.logGameEvent(follower, "game-party-enter-select-follower-deferred",
|
|
"source", source,
|
|
"leader_char_id", leaderCharacterID,
|
|
"party_id", partyID,
|
|
"reason", reason)
|
|
return false, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, follower)
|
|
if charID == 0 {
|
|
charID = follower.selectedCharacterID
|
|
}
|
|
partyState := runtimePartyStateSnapshot(follower)
|
|
if partyState.PartyID != partyID || partyState.UserID != leaderCharacterID {
|
|
return false, nil
|
|
}
|
|
fatigueBody := buildCurrentFatigueBody(character, hasCharacter)
|
|
if err := s.sendGameUpperRawClass(follower, currentFatigueMsgID, fatigueBody, 0); err != nil {
|
|
return false, err
|
|
}
|
|
if err := s.sendCurrentDungeonPermissionSnapshot(ctx, follower, source+"_after_fatigue"); err != nil {
|
|
return false, err
|
|
}
|
|
if !follower.enterSelectDungeonContextSent {
|
|
if err := s.sendCurrentTownEnterSelectContext(follower, source+"_after_fatigue"); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
if !follower.enterSelectDungeonContextSent {
|
|
return false, nil
|
|
}
|
|
follower.enterSelectDungeonSent = true
|
|
follower.enterSelectDungeonAckSent = false
|
|
follower.enterSelectDungeonPassiveParty = true
|
|
if err := s.sendRuntimeRaidSelectorSnapshot(follower, source+"_after_passive_party_selector_context"); err != nil {
|
|
return false, err
|
|
}
|
|
s.logGameEvent(follower, "game-party-enter-select-follower-synchronized",
|
|
"source", source,
|
|
"leader_char_id", leaderCharacterID,
|
|
"char_id", charID,
|
|
"party_id", partyID,
|
|
"sequence", "leader_selector_then_follower_op36_own_fatigue_op5_leader_authority_op27_without_op15_ack")
|
|
return true, nil
|
|
}
|