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