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

441 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"fmt"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func selectInitPacketAllowedBeforeScene(packet csharpSelectInitPacket) bool {
return packet.kind == "select_ack"
}
func selectInitPacketSupersededByLongHengScene(packet csharpSelectInitPacket) bool {
switch packet.kind {
case "select_load_empty", "select_scene_probe", "select_scene_pet_list", "select_scene_userinfo", "select_scene_safe_error":
return true
}
return packet.kind == "" &&
packet.class == 0 &&
packet.msgID == uint16(dnfenum.CmdPacketLeaveParty) &&
len(packet.body) == 5 &&
packet.body[0] <= 2
}
func selectInitPacketRequiresCurrentSceneObject(packet csharpSelectInitPacket) bool {
return packet.msgID == uint16(dnfenum.CmdPacketInsertOverseer)
}
func (s *Service) sendFinishLoadingStatus(session *gameSession, request []byte) error {
if session == nil {
return nil
}
// sub_217E0C0 writes two semantic u32 zero values, but the current client
// can send op37 through more than one protected/legacy boundary. Production
// evidence on 2026-07-21 shows the live EXE sends the eight-byte semantic
// body (two zero u32 values) after dungeon op29, while older captures used
// the 16-byte protected wrapper. Accept only these two proven shapes before
// sending the ACK or advancing any finish-loading lifecycle gate.
if !currentFinishLoadingRequestBodyAccepted(request) {
s.logGameEvent(session, "game-upper-finish-loading-request-rejected",
"request_body_len", len(request),
"expected_body_lens", "8_or_16",
"reason", "current_live_legacy_op37_body_length_mismatch")
return nil
}
actorReady, actorReadySource := s.currentSceneActorReadyForState(session)
s.logGameEvent(session, "game-upper-finish-loading-status-send",
"msg_id", uint16(dnfenum.CmdPacketFinishLoading),
"classification", dnfproto.DefaultChannelClassification,
"request_body_len", len(request),
"plain_body_len", 1,
"body_source", "current_exe_sub_1CF50C0_success_reads_no_body_after_discriminator",
"actor_ready_source", actorReadySource,
"main_op37", "sent_after_status_when_selected_actor_and_scene_transition_are_complete")
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketFinishLoading), nil); err != nil {
return err
}
// A completed dungeon return already published its town actor/scene state.
// The current client nevertheless emits its normal op37 request after that
// transition. Replaying the class0/op37 character-state reader at this
// point opens the personal-information refresh UI. Keep the proven empty
// status ACK, but do not turn this return-side request into a second
// progression snapshot.
if session.returnTownFinishLoadingAckOnly {
if err := s.ensureCurrentConfirmedDungeonReturnPlayerState(
session,
"request37_after_confirmed_direct_return",
); err != nil {
s.logWarn("dnfbridge deferred confirmed return player state after op37 ACK",
"conn_id", session.connID,
"char_id", session.selectedCharacterID,
"error", err)
// The success ACK may already be on the wire, but the town
// actor/HUD generation is not complete. Propagate the failure so
// the connection cannot continue in a falsely-ready state; the
// staged cursor and confirmation marker remain available to the
// next current-client confirmation or reconnect.
return err
}
s.logGameEvent(session, "game-upper-finish-loading-return-town-ack-only",
"request_body_len", len(request),
"reason", "completed_dungeon_town_state_already_published")
return nil
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
var partyDungeonOwner *runtimePartyDungeonOwner
if runtime != nil {
partyDungeonOwner = runtime.partyDungeonOwner
}
session.dungeon.mu.Unlock()
if partyDungeonOwner != nil {
// A previous barrier release can have completed its actor projection and
// then failed on the final empty class0/op42 reset. registerReady is
// intentionally one-shot, so repair that suffix before its duplicate-ready
// fast path. Never reset before the op120/op19 projection is complete.
if session.currentFinishLoadingCompletionSent && session.postFinishLoadingPlayerStateSent {
if err := s.sendCurrentDungeonEntryConsumableUseGateReset(
session,
"party_dungeon_duplicate_finish_loading_retry_after_op37_op30",
); err != nil {
return err
}
}
registration, err := partyDungeonOwner.registerReady(
session.selectedCharacterID,
session.characterGeneration,
)
if err != nil {
return err
}
s.logGameEvent(session, "game-party-dungeon-loading-ready",
"party_id", partyDungeonOwner.partyID,
"slot", registration.slot,
"cycle", registration.cycle,
"first", registration.first,
"release", registration.release)
// 团本记录:读条屏障的每一次登记。ready/expected 能直接回答
// "全队卡在读条时到底谁没到",is_driver 能回答"是谁在替全队放行"。
readyBits, expectedBits, released, cycle := partyDungeonOwner.readySnapshot()
s.recordRaidEvent(session, "loading_ready", map[string]any{
"party_id": partyDungeonOwner.partyID,
"party_leader_id": partyDungeonOwner.leaderID,
"loading_cycle": cycle,
"registration_cycle": registration.cycle,
"slot": int(registration.slot),
"ready_first": registration.first,
"is_driver": registration.release,
"ready_mask": readyBits,
"expected_mask": expectedBits,
"ready_count": raidBitsCount(readyBits),
"expected_count": raidBitsCount(expectedBits),
"barrier_released": released,
"missing_slots": raidMissingSlots(readyBits, expectedBits),
})
if !registration.first || !registration.release {
return nil
}
// op26 makes the leader slot the native UDP/controller authority on every
// client. Finish every follower's post-loading actor chain before the
// leader receives its own release; otherwise the leader can start room
// combat while another peer still lacks the room-ready state. The initial
// loading cycle also installs op19; later cycles deliberately preserve the
// live skill/Buff manager instead of rebuilding it on every room change.
// 并发释放,但顺序必须满足上面的约束:所有【非队长】成员的 release 先完成,
// 队长最后才 release。TestPartyLeaderDungeonSelectionEntersEveryOnlineMemberWithStablePartyIndex
// 正是按「follower 的 loading 链整体先于 leader 的 release」断言的。
//
// 改前是「逐个成员现开一个 createWriteTimeout 再同步等」,N 个成员就是
// 3s × (N-1) 的串行累加,把本会话的 event loop 卡住数秒;实测 2026-09-25
// 00:27 单成员自身就要 5.95s,3s 预算必然超时并让整个 finish-loading
// 流程中止。并发后总耗时从 Σ 变成 max。
roster := partyDungeonOwner.loadingReleaseRoster()
leaderID := partyDungeonOwner.leaderID
// 自己必须【直接】调用,绝不能走 callGameSession:那是对自己的 event loop
// 发一次同步等待,而当前正跑在该 loop 上,会自锁到预算耗尽。
releaseSelf := func() error {
return s.completeRuntimePartyDungeonLoading(session, partyDungeonOwner, registration.cycle)
}
releaseFollower := func(target sessionFanoutTarget) error {
return s.completeRuntimePartyDungeonLoading(target.session, partyDungeonOwner, registration.cycle)
}
var followers []sessionFanoutTarget
var leaderTarget *sessionFanoutTarget
selfInRoster, selfIsLeader := false, false
var structuralErr error
for _, member := range roster {
if member.userID == session.selectedCharacterID &&
member.generation == session.characterGeneration {
selfInRoster = true
selfIsLeader = member.userID == leaderID
continue
}
identity, current := s.onlineGameSessionCharacterSnapshot(member.userID)
if !current || identity.session == nil || identity.generation != member.generation {
// 这个成员的会话已经换了一代(通常是掉线或重连)。改前这里直接中止,
// 会让排在其后的成员连 release 都拿不到;改为记下错误,先把其余仍然
// 有效的成员放行,最后再把这个错误交给调用方。
if structuralErr == nil {
structuralErr = fmt.Errorf("party dungeon loading member %d session generation changed", member.userID)
}
continue
}
target := sessionFanoutTarget{
label: fmt.Sprintf("%d", member.userID),
characterID: member.userID,
session: identity.session,
}
if member.userID == leaderID {
leader := target
leaderTarget = &leader
continue
}
followers = append(followers, target)
}
releaseStarted := time.Now()
// 队长会话:自己就是队长时用本会话,否则用上面解析到的那个。
leaderSession, leaderCharID := session, session.selectedCharacterID
if leaderTarget != nil {
leaderSession, leaderCharID = leaderTarget.session, leaderTarget.characterID
}
releaseLeader := func() error {
return s.completeRuntimePartyDungeonLoading(leaderSession, partyDungeonOwner, registration.cycle)
}
// 第一步:非队长成员。自己若是 follower,先直接调用放行(必须在自己的
// loop 上直接调用;走 callGameSession 就是对自己的 loop 发同步等待,会自锁)。
selfReleasedInline := false
if selfInRoster && !selfIsLeader {
if err := releaseSelf(); err != nil {
return err
}
partyDungeonOwner.markLoadingReleased(session.selectedCharacterID, registration.cycle)
selfReleasedInline = true
}
// 第二步:队长最后放行。两条路:
//
// - 没有任何需要先完成的 follower(例如只有队长一人的队伍):顺序要求空真,
// 走同步路径,与旧行为完全一致,也不让这种队伍白等一个事件循环轮次;
// - 有 follower:必须**投递**。同步等每个成员的 loop 就是本次修复要消灭的
// 那个 10 秒停摆 —— 多人同时进图时每个 loop 都停在同一个等待里,只能等
// 预算到点(实测发起者 loop 被停住 10.0004 秒,那 10 秒它一个包都处理不了)。
// then 只在全员完成后被调用,因此"follower 的链整体先于队长的 release"
// 这条线序契约由构造保证;then 可能跑在最后一个完成者的 loop 上,也可能
// 跑在预算到点的计时器协程上,所以它只允许投递,不允许直接改会话状态。
leaderRelease := func() error {
if selfInRoster && selfIsLeader {
return releaseLeader()
}
if leaderTarget != nil {
return releaseFollower(*leaderTarget)
}
return nil
}
leaderReleaseInRoster := selfInRoster && selfIsLeader
if len(followers) == 0 {
if leaderReleaseInRoster || leaderTarget != nil {
if err := leaderRelease(); err != nil {
return err
}
partyDungeonOwner.markLoadingReleased(leaderCharID, registration.cycle)
}
} else {
s.fanOutToGameSessionsAsync(context.Background(), followers,
"party-dungeon-loading-release", releaseFollower, func() {
if !leaderReleaseInRoster && leaderTarget == nil {
return
}
// 自己就是队长 → 投给自己;否则投给队长。
target := session
targetCharID := leaderCharID
if !leaderReleaseInRoster && leaderTarget != nil {
target = leaderTarget.session
targetCharID = leaderTarget.characterID
}
if err := s.postGameSessionCharacterEvent(
target, "raid-loading-leader-last",
targetCharID, target.characterGeneration,
func() error {
if err := leaderRelease(); err != nil {
return err
}
partyDungeonOwner.markLoadingReleased(targetCharID, registration.cycle)
return nil
},
); err != nil {
s.recordRaidEvent(session, "loading_leader_release_deferred", map[string]any{
"party_id": partyDungeonOwner.partyID,
"loading_cycle": registration.cycle,
"reason": "leader_loop_submit_failed",
"error": errorText(err),
})
}
})
}
// 团本记录:这一次读条放行的形状与耗时。total_wall_ms 现在是"本成员自己这
// 一步"的墙钟(不再是"等全队"的时间),follower_count 是被投递出去的成员数。
s.recordRaidEvent(session, "loading_release_driver", map[string]any{
"party_id": partyDungeonOwner.partyID,
"party_leader_id": partyDungeonOwner.leaderID,
"loading_cycle": registration.cycle,
"is_leader": selfIsLeader,
"self_in_roster": selfInRoster,
"self_released_inline": selfReleasedInline,
"follower_count": len(followers),
"leader_target_present": leaderTarget != nil,
"total_wall_ms": raidMillis(time.Since(releaseStarted)),
"structural_error": errorText(structuralErr),
})
return structuralErr
}
if !actorReady {
s.logGameEvent(session, "game-main-finish-loading-state-deferred",
"request_body_len", len(request),
"actor_ready_source", actorReadySource,
"reason", "selected_actor_or_scene_transition_incomplete")
return nil
}
if err := s.sendCurrentFinishLoadingCharacterState(session, "request37_after_upper_status"); err != nil {
return err
}
if err := s.sendCompletedTutorialReentryExit(session, "request37_after_current_finish_loading_state"); err != nil {
return err
}
if err := s.sendRuntimeRaidDungeonParticipationOnFinishLoading(session); err != nil {
return err
}
if err := s.startCurrentSuspiciousVillageElevator(session, "request37_after_current_finish_loading_state"); err != nil {
return err
}
if err := s.sendCurrentHellPartyMonsterInfoAfterFinishLoading(session, "request37_after_scene_completion"); err != nil {
return err
}
// 装载完成后把卢克的阶段复活币已用数补发给这一个成员(重连 / 后进队的人靠它对齐)。
// 非卢克时该函数内部直接返回,不改动任何既有行为。
s.syncRuntimeRaidPhaseCoinUsageAfterFinishLoading(session, "request37_after_scene_completion")
// 物件脚本门(上游 b402882):装载完成后把被动对象脚本已经打开的 GATE ENABLE 补发出去。
s.publishCurrentDungeonActionObjectGateAfterFinishLoading(session, "request37_after_scene_completion")
// op120 and the optional tutorial/raid/elevator/Hell suffix can all run
// after op37/op30. The current EXE's native item-use wait byte must be reset
// only after that entire solo loading chain is complete. A failed write keeps
// the dungeon-generation marker armed for a duplicate op37 retry.
return s.sendCurrentDungeonEntryConsumableUseGateReset(
session,
"request37_after_complete_solo_loading_projection",
)
}
func (s *Service) sendContentsPlayInfoState(session *gameSession, body []byte) error {
s.logGameEvent(session, "game-contents-play-info-deferred",
"body_len", len(body),
"reason", "dove_scene_flow_has_no_s2c_704_and_current_client_opens_side_panel")
// DOVE 完整进场景 S2C 流没有 704;当前 NoPack 收到 704 成功体会打开右侧公告/聊天面板。
return nil
}
func (s *Service) sendRequestBlacklistState(session *gameSession, source string, request []byte) error {
if session == nil {
return nil
}
// Hold the answer back until the select-character scene tail is committed. The
// client builds each row against the scene it is standing in, and a row built
// while the bootstrap is still pending comes out with an empty 黑名单日期 cell (its
// level is 0 for the same reason). Live 2026-09-22 23:46 (conn game-000245): the
// client asked six times, the first three bracketed the tail commit and rendered
// the date, then a route transition re-deferred the tail and the last three
// answers rebuilt the row without it. The client asks again on its own, and
// sendCurrentDeferredSelectSceneTail flushes the pending answer when the tail
// commits, so nothing is lost when it does not.
if !session.sceneBootstrapTailSent {
session.blacklistListAnswerPending = true
s.logPacketEvent("game-upper-request-blacklist-held-until-scene-tail",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", uint16(dnfenum.CmdPacketRequestBlacklist),
"request_body_len", len(request),
"reason", "select_character_scene_tail_not_committed_a_row_built_now_loses_its_blacklist_date")
return nil
}
return s.sendCurrentBlacklistListAnswer(session, source, request)
}
// sendCurrentBlacklistListAnswer is the type=120 answer itself.
//
// The stored list is projected onto the layout the client's op120 reader expects
// (see blacklist.go): `u8 flag | u8 count | count * (u32 name | name | y | m | d)`.
// An empty list answers with currentRequestBlacklistResponseBody verbatim, which is
// byte for byte what this path sent before the layout was known.
func (s *Service) sendCurrentBlacklistListAnswer(session *gameSession, source string, request []byte) error {
records := s.loadCurrentBlacklistRecordsForSession(session)
responseBody := buildCurrentBlacklistListBody(records)
session.blacklistListAnswerPending = false
s.logPacketEvent("game-upper-request-blacklist-passive-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", uint16(dnfenum.CmdPacketRequestBlacklist),
"classification", dnfproto.DefaultChannelClassification,
"request_body_len", len(request),
"plain_body_len", len(responseBody),
"blacklist_count", len(records),
"body_source", "current_upper_op120_blacklist_result_flag_count_name_date",
"reason", "current_nopack_upper_op120_is_distinct_from_main_scene_op120")
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRequestBlacklist), responseBody, dnfproto.DefaultChannelClassification); err != nil {
return err
}
if !session.sceneBootstrapTailSent {
s.logPacketEvent("game-upper-request-blacklist-before-hud-replay-skipped",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"reason", "current_object_create_mode0_is_one_shot_runtime_uses_after_blacklist_structures")
}
if !session.postStartMapPlayerStateSent {
s.logPacketEvent("game-upper-request-blacklist-runtime-seed-deferred",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"reason", "blacklist_ack_is_request_driven_but_runtime_seed_waits_for_op29_player_state")
return nil
}
if err := s.sendRuntimeAfterBlacklistSeedOnce(session, source+"_runtime_seed"); err != nil {
return err
}
s.logPacketEvent("game-upper-request-blacklist-runtime-seed-current-struct-complete",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"reason", "runtime_after_blacklist_sequence_sent_without_replaying_before_hud_object_create")
return nil
}
func (s *Service) sendGuildAllMemberListState(session *gameSession, source string, request []byte) error {
s.logPacketEvent("game-upper-guild-all-member-list-passive-deferred",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", uint16(dnfenum.CmdPacketGuildAllMemberList),
"classification", dnfproto.DefaultChannelClassification,
"request_body_len", len(request),
"plain_body_len", 0,
"reason", "defer_guild_member_list_during_select_scene_bootstrap_no_dove_replay")
// 当前 NoPack 在选角进场景前收到 op140 大表会拉起右侧社交/系统面板;先延后响应,等场景稳定后再恢复真实公会面板。
return nil
}