Files
90CNServer/server/internal/services/dnfbridge/game_enter_select_dungeon.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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
}