DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,435 @@
package dnfbridge
import (
"context"
"fmt"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func (s *Service) sendUpperCSharpSelectInit(session *gameSession, request []byte) (err error) {
packets := expandedCSharpUpperSelectInitTemplate()
var legacyRepo dnfrepo.LegacyUserInfoRepository
var selectedQuests dnfrepo.QuestRecord
hasSelectedQuests := false
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
charID, charName, selectedCharacter, hasSelectedCharacter, slot := s.resolveSelectedCharacter(ctx, session, request)
if !hasSelectedCharacter {
s.logPacketEvent("game-upper-select-character-init-blocked",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"slot", slot,
"char_id", charID,
"request_body_len", len(request),
"reason", "select_request_not_decoded")
return nil
}
if beginCurrentSelectCharacterInit(session, charID) {
s.logGameEvent(session, "game-upper-select-character-init-duplicate-suppressed",
"char_id", charID,
"request_body_len", len(request),
"reason", "same_character_select_lifecycle_already_active")
return nil
}
defer func() {
if err != nil {
abortCurrentSelectCharacterInit(session, charID)
}
}()
selectedCharacter, _, err = s.reconcileCurrentGameOptionVisibilityForSelectedCharacter(
ctx,
session,
selectedCharacter,
)
if err != nil {
return fmt.Errorf("reconcile selected character game-option visibility: %w", err)
}
s.bindGameSessionCharacter(session, charID)
sameCharacterReturn := sameCharacterReturnSelect(session, charID)
if activationErr := s.reconcileCurrentPremiumInventoryBeforeList(
ctx,
session,
"select_character_before_premium_ack_and_inventory_bootstrap",
false,
); activationErr != nil {
// Selection remains recoverable: the untouched item is projected and
// can still be used manually, while the next list snapshot retries the
// automatic conversion.
s.logGameEvent(session, "game-upper-select-character-premium-inventory-auto-activation-deferred",
"char_id", charID,
"reason", activationErr,
"fallback", "retain_contract_item_and_retry_before_list0")
}
var blackDiamondAccount dnfrepo.AccountRecord
blackDiamondAccountFound := false
blackDiamondResult, blackDiamondErr := s.reconcileCurrentBlackDiamondSelection(ctx, session, selectedCharacter)
if blackDiamondErr != nil {
// Selection stays available if storage is temporarily unavailable. The
// next login retries migration/fatigue reconciliation and the account
// expiry itself remains authoritative for settlement rewards.
s.logGameEvent(session, "game-black-diamond-selection-reconcile-deferred",
"char_id", charID,
"reason", blackDiamondErr)
} else if blackDiamondResult.Found {
selectedCharacter = blackDiamondResult.Character
blackDiamondAccount = blackDiamondResult.Account
blackDiamondAccountFound = true
s.logGameEvent(session, "game-black-diamond-selection-reconciled",
"char_id", charID,
"active", blackDiamondResult.Active,
"expires_at", blackDiamondResult.ExpiresAt,
"fatigue", selectedCharacter.Stats["fatigue"],
"fatigue_limit", selectedCharacter.Stats["fatigue_limit"],
"wire_type", premium.TypeBlackDiamondWire,
"entitlement_type", premium.TypeBlackDiamondEntitlement)
}
dailyFatigueResult, dailyFatigueErr := s.reconcileCurrentDailyFatigueSelection(ctx, session)
if dailyFatigueErr != nil {
s.logGameEvent(session, "game-daily-fatigue-selection-reconcile-deferred",
"char_id", charID,
"reason", dailyFatigueErr)
} else {
if dailyFatigueResult.Character.CharacterID != "" {
selectedCharacter = dailyFatigueResult.Character
s.logGameEvent(session, "game-daily-fatigue-selection-reconciled",
"char_id", charID,
"service_day_id", dailyFatigueResult.ServiceDayID,
"changed", dailyFatigueResult.Changed,
"fatigue_restored", dailyFatigueResult.FatigueRestored,
"potion_usage_reset", dailyFatigueResult.PotionUsageReset,
"fatigue", selectedCharacter.Stats["fatigue"],
"fatigue_limit", selectedCharacter.Stats["fatigue_limit"])
}
}
dailyDungeonResult, dailyDungeonErr := s.reconcileCurrentDailyDungeonSelection(ctx, session)
if dailyDungeonErr != nil {
s.logGameEvent(session, "game-daily-dungeon-selection-reconcile-deferred",
"char_id", charID,
"reason", dailyDungeonErr)
} else if !dailyDungeonResult.Window.Start.IsZero() {
s.logGameEvent(session, "game-daily-dungeon-selection-reconciled",
"char_id", charID,
"service_window_start", dailyDungeonResult.Window.Start.Format(time.RFC3339),
"changed", dailyDungeonResult.Changed,
"counters_changed", dailyDungeonResult.CountersChanged,
"inventory_changed", dailyDungeonResult.InventoryChanged,
"reset_policy_count", len(dailyDungeonResult.ResetPolicyKeys),
"invitation_count", len(dailyDungeonResult.Invitations))
}
if dailyFatigueErr != nil || dailyDungeonErr != nil {
if retryErr := s.retryCurrentDailyFatigueReset(session); retryErr != nil {
s.logGameEvent(session, "game-daily-fatigue-reset-retry-schedule-failed",
"char_id", charID,
"reason", "selection_daily_reconcile_failed",
"error", retryErr)
}
} else if timerErr := s.armCurrentDailyFatigueReset(session); timerErr != nil {
s.logGameEvent(session, "game-daily-fatigue-reset-timer-arm-deferred",
"char_id", charID,
"error", timerErr)
}
s.persistCurrentSelectorAdventureInfoSlot(ctx, session, slot)
session.selectedCreatureStateTableSent = false
s.resetCurrentInitialTownRoute(session)
if stats, ok := s.characterPVFStatsForUserInfo(ctx, session, selectedCharacter, hasSelectedCharacter); ok {
applyCharacterPVFStats(&selectedCharacter, stats)
}
if preSyncErr := s.preSyncGuardianShieldMainProjection(ctx, session, selectedCharacter, hasSelectedCharacter); preSyncErr != nil {
s.logGameEvent(session, "game-upper-select-guardian-shield-main-projection-presync-deferred",
"char_id", charID,
"error", preSyncErr)
}
ackTownID, townErr := currentSelectAckTownID(session, selectedCharacter)
if townErr != nil {
s.logPacketEvent("game-upper-select-character-init-blocked",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"slot", slot,
"char_id", charID,
"request_body_len", len(request),
"reason", townErr)
return townErr
}
if repos, ok := s.repositoryGroup(); ok {
legacyRepo = repos.LegacyUserInfo
if repos.Quest != nil {
characterID := selectedCharacter.CharacterID
if characterID == "" {
characterID = strconv.Itoa(int(charID))
}
var err error
selectedQuests, hasSelectedQuests, err = repos.Quest.Load(ctx, characterID)
if err != nil {
hasSelectedQuests = false
s.logGameEvent(session, "game-upper-select-character-quest-load-failed",
"char_id", charID,
"error", err)
} else if hasSelectedQuests {
// The select ACK carries the quest projection the client boots
// from; retire the legacy Anton team mission before it is built
// so a Luke-channel login sees the mission as already complete.
var defaultCompleted bool
selectedQuests, defaultCompleted, err = s.reconcileAntonRaidMissionDefaultCompletion(
ctx,
session,
repos,
characterID,
selectedQuests,
)
if err != nil {
s.logGameEvent(session, "game-upper-select-character-anton-raid-mission-default-completion-deferred",
"char_id", charID,
"error", err)
} else if defaultCompleted {
hasSelectedQuests = true
}
}
}
}
s.logPacketEvent("game-upper-select-character-csharp-init-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"slot", slot,
"char_id", charID,
"packet_count", len(packets))
accountCera := uint32(0)
premiumEntries := []byte{0}
if repos, ok := s.repositoryGroup(); ok {
if balance := s.loadCurrentAccountCera(ctx, repos, session); balance > 0 {
if balance > int64(^uint32(0)) {
balance = int64(^uint32(0))
}
accountCera = uint32(balance)
}
if blackDiamondAccountFound {
premiumEntries = currentSelectAckPremiumEntries(blackDiamondAccount, s.gameplayNow().UTC())
} else if repos.Account != nil {
if account, found, err := repos.Account.Load(ctx, s.accountIDForSession(session)); err == nil && found {
premiumEntries = currentSelectAckPremiumEntries(account, s.gameplayNow().UTC())
s.setCurrentBlackDiamondExpiry(session, currentBlackDiamondExpiration(account))
}
}
}
for _, packet := range packets {
body := packet.body
if packet.kind == "userinfo" {
s.logGameEvent(session, "game-upper-select-character-userinfo-deferred",
"char_id", charID,
"occurrence", packet.occurrence,
"reason", "selected_userinfo_refresh_waits_for_enter_scene_state")
continue
}
if packet.kind == "select_deferred" {
s.logGameEvent(session, "game-upper-select-character-packet-deferred",
"char_id", charID,
"msg_id", packet.msgID,
"reason", "client_crashes_when_sent_before_enter_scene_state")
continue
}
if !selectInitPacketAllowedBeforeScene(packet) {
s.logGameEvent(session, "game-upper-select-character-packet-deferred",
"char_id", charID,
"msg_id", packet.msgID,
"kind", packet.kind,
"reason", "wait_for_client_request_or_scene_state")
continue
}
switch packet.kind {
case "select_ack":
body = buildCurrentSelectCharacterAckBody(selectedCharacter, hasSelectedCharacter, selectedQuests, hasSelectedQuests, charID, ackTownID, accountCera, premiumEntries)
fixedQuestRows, overflowQuestRows, questRowsOK := currentSelectAckQuestRowCounts(body)
ackCharacterID, ackCharacterIDOK := currentSelectAckCharacterID(body)
ackTownIDFromBody, ackTownIDOK := currentSelectAckTownIDFromBody(body)
ackIntermediateState, ackIntermediateOffset, ackIntermediateOK := currentSelectAckIntermediateState(body)
tutorialFlag, tutorialCount, tutorialStateOK := currentSelectAckTutorialState(body)
tutorialIndexes, tutorialIndexesOK := currentSelectAckTutorialIndexes(body)
fatigue := currentCharacterFatigueState(selectedCharacter, hasSelectedCharacter)
s.logPacketEvent("game-upper-select-character-ack-current-fields",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", charID,
"slot", slot,
"quest_record_found", hasSelectedQuests,
"quest_state_count", len(selectedQuests.States),
"fixed_quest_rows", fixedQuestRows,
"overflow_quest_rows", overflowQuestRows,
"quest_rows_ok", questRowsOK,
"ack_character_id", ackCharacterID,
"ack_character_id_ok", ackCharacterIDOK,
"town_id", ackTownIDFromBody,
"town_id_ok", ackTownIDOK,
"intermediate_state_offset", ackIntermediateOffset,
"intermediate_state_ok", ackIntermediateOK,
"intermediate_state0", ackIntermediateState[0],
"intermediate_state1", ackIntermediateState[1],
"intermediate_state2", ackIntermediateState[2],
"intermediate_state3", ackIntermediateState[3],
"tutorial_ignored_flag", tutorialFlag,
"tutorial_count", tutorialCount,
"tutorial_state_ok", tutorialStateOK,
"tutorial_indexes", tutorialIndexes,
"tutorial_indexes_ok", tutorialIndexesOK,
"server_tutorial_completed", hasPersistedDungeonTutorialCompletion(selectedCharacter),
"fatigue_used", fatigue.used,
"fatigue_limit", fatigue.limit,
"premium_count", premiumEntries[0],
"same_character_return_select", sameCharacterReturn,
"body_len", len(body),
"body_source", "current_exe_sub_1D01C50_sub_1A0C3E0_4u32_character_quest_slot_typed")
case "select_scene_userinfo":
body = s.buildCSharpSelectedUserInfoBody(ctx, session, legacyRepo, packet.occurrence, selectedCharacter, hasSelectedCharacter, charID, charName)
s.logPacketEvent("game-upper-select-character-scene-userinfo-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", charID,
"occurrence", packet.occurrence,
"msg_id", packet.msgID,
"body_len", len(body),
"source", "csharp_select_init_before_scene_object_chain")
case "userinfo":
body = s.buildCSharpSelectedUserInfoBody(ctx, session, legacyRepo, packet.occurrence, selectedCharacter, hasSelectedCharacter, charID, charName)
case csharpLegacyUserInfoKind:
var ok bool
body, ok = s.buildCSharpLegacyUserInfoBody(ctx, session, legacyRepo, charID, packet.msgID)
if !ok {
continue
}
case csharpCurrentActionTableStateKind:
s.logPacketEvent("game-upper-current-action-table-state-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"char_id", charID,
"msg_id", packet.msgID,
"body_len", len(body),
"body_source", "current_exe_sub_217D850_u8_u8_u8",
"selector_source", "decoded_dword_51B0F14_F18",
"reason", "initialize_sub_3851100_active_table_before_mode0_object")
case csharpCurrentSceneObjectListKind:
body = s.buildCurrentSceneObjectListBodyForSession(ctx, session, charID, charName, selectedCharacter, hasSelectedCharacter)
case csharpCurrentSceneOp9ActorDisplayKind:
objectKey := currentSceneActorObjectKey(charID)
body = buildCurrentSceneOp9ActorDisplayBodyInContext(
objectKey,
selectedCharacter,
hasSelectedCharacter,
charName,
currentSceneObjectContext,
)
op9Name := charName
if op9Name == "" && hasSelectedCharacter {
op9Name = selectedCharacter.Name
}
s.logGameEvent(session, "game-upper-scene-op9-current-actor-display-send",
"char_id", charID,
"msg_id", packet.msgID,
"scene_object_key", objectKey,
"scene_value", currentSceneOp9StableSceneValue,
"body_len", len(body),
"record_kind", currentSceneOp9ActorDisplayKind,
"name_len", len(rosterNameBytes(op9Name)),
"slot_count", 0,
"follow_mode", 0)
case "title_book":
// Send real title book lists from inventory (list 100) instead of empty fixture.
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
tbCtx, tbCancel := context.WithTimeout(context.Background(), createWriteTimeout)
repos, _ := s.repositoryGroup()
if repos.Inventory != nil {
_ = s.sendAllCurrentTitleBookLists(session, tbCtx, repos, characterID)
}
tbCancel()
// Still send the empty fixture for categories the client expects at init.
body = buildCSharpTitleBookBody(packet.occurrence)
case "daily_schedule":
body = buildCSharpDailyScheduleBody(packet.occurrence)
}
if err := s.sendCSharpSelectInitPacket(session, packet, body); err != nil {
return err
}
if packet.kind == csharpCurrentSceneObjectListKind {
if err := s.sendSelectedSceneUserInfo23Mode1Only(session, "select_character_after_current_object_list"); err != nil {
return err
}
}
if packet.kind == "select_ack" {
if !returnSelectTownReentryPending(session) {
// Cold selection needs these caches before its client-owned progress
// boundary. Return-select publishes them after the immediate scene
// commit so the previous town cannot become visible first.
if err := s.sendCollectboxInitializationStates(ctx, session); err != nil {
s.logGameEvent(session, "game-collectbox-init-status-deferred",
"char_id", charID,
"reason", err)
}
if err := s.sendCurrentDungeonPermissionSnapshot(ctx, session, "select_character_after_ack_before_worldmap_scene"); err != nil {
return err
}
}
// Current EXE sub_11DBE40 consumes class0/op1378 raw u32 before
// the selected scene is finalized. Send the durable per-character
// story-summary level immediately after the select ACK, before the
// tutorial/town route can emit any scene packet.
//
// 这里下发的是**持久化值本身**,不做任何"补齐到角色等级"的推进:
// 0 表示"还没接受过任何摘要",把它推到角色等级会让低等级段
// (例如等级 1 的新角色)的摘要永远不再播放。
if err := s.sendCurrentStoryDigestLastLevel(session, selectedCharacter, hasSelectedCharacter, "select_character_after_ack_before_scene"); err != nil {
return err
}
startsInTutorial := selectedCharacterStartsInTutorial(selectedCharacter, hasSelectedCharacter)
if session.channelReconnect {
clearReturnSelectTownReentry(session)
s.logGameEvent(session, "game-channel-reconnect-normal-select-route-skipped",
"char_id", charID,
"target_channel", session.channel.ID,
"reason", "fresh_target_channel_town_route_runs_after_op4_bootstrap")
} else if startsInTutorial {
clearReturnSelectTownReentry(session)
s.logGameEvent(session, "game-upper-current-scene-transition-op24-deferred",
"source", "select_character_after_ack",
"char_id", charID,
"msg_id", currentSceneTransitionMsgID,
"tutorial_completed", false,
"reason", "tutorial_pending_first_scene_is_dungeon_not_town")
if err := s.sendEnterSelectDungeonState(session, "select_character_after_ack_15", false, false); err != nil {
return err
}
if err := s.sendCurrentSelectPreviewObjectState(ctx, session, charID, charName, selectedCharacter, hasSelectedCharacter, "select_character_after_scene_gate_op15"); err != nil {
return err
}
} else {
s.armCurrentInitialTownRoute(session, charID)
if returnSelectTownReentryPending(session) {
if err := s.resumeCurrentInitialTownRouteAfterReturnSelect(session); err != nil {
return err
}
} else {
s.logGameEvent(session, "game-upper-select-character-tutorial-preview-skipped",
"source", "select_character_after_ack",
"char_id", charID,
"tutorial_completed", hasPersistedDungeonTutorialCompletion(selectedCharacter),
"tutorial_route_index", currentSelectAckPage1RouteIndex,
"reason", "current_exe_select_ack_switches_to_page1_and_arms_client_requested_progress36_town_route")
}
}
}
}
if session.channelReconnect {
return s.sendCurrentChannelReconnectTownEntry(session)
}
session.sceneBootstrapTailDeferred = true
session.sceneBootstrapTailSent = false
resetCurrentDeferredSelectSceneTailProgress(session)
s.logGameEvent(session, "game-select-scene-tail-deferred",
"source", "select_character_wait_for_current_op29",
"char_id", session.selectedCharacterID,
"reason", "preview_eviction_then_final_actor_binding_before_op27_and_full_state_after_op29",
"deferred_sequence", "validated_op16_op9_kind3_preview_remove_then_op5_final_mode0_actor_binding_mode1_op27_op28_op29_full_mode1_mode3")
return nil
}