Files
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

376 lines
14 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
func (s *Service) armCurrentInitialTownRoute(session *gameSession, characterID uint16) {
if session == nil || characterID == 0 {
return
}
session.townMu.Lock()
resetCurrentTownPostTransitionPlayerState(session)
session.initialTownRouteCharacterID = characterID
session.initialTownRouteStage = currentInitialTownRouteArmed
session.initialTownGuardianGemRefreshSent = false
session.initialTownGuardianShieldDeckSent = false
session.initialTownGuardianShieldAppearanceSent = false
session.chatEmoticonCatalogSent = false
session.initialTownActorSceneSnapshotSent = false
session.initialTownActorDisplaySent = false
session.initialTownActorPlacementSent = false
session.initialTownDungeonPermissionSent = false
session.initialTownLocationNotificationsSent = false
session.initialTownQuestSnapshotsSent = false
session.initialTownQuestHUDRefreshSent = false
session.initialTownGuildStateProjectionSent = false
session.initialTownSkillInfoPrepared = false
session.initialTownSkillInfoSent = false
session.initialTownSkillInfo = currentSceneSkillInfoProjection{}
session.initialTownMode3RefreshPending = false
session.completedTownReturnBuffRegistrationPending = false
session.initialTownLegacySceneReadyAccepted = false
session.initialTownAdventureOverheadRefreshSent = false
session.initialTownCombatPowerAffixesSent = false
session.initialTownMoodProjectionSent = false
session.crystalContractMu.Lock()
session.crystalContractTownUIReadyStateSent = false
session.crystalContractMu.Unlock()
session.auraSkinMu.Lock()
session.auraSkinTownUIReadyStateSent = false
session.auraSkinMu.Unlock()
session.townMu.Unlock()
s.logGameEvent(session, "game-initial-town-route-armed",
"char_id", characterID,
"tutorial_route_index", currentSelectAckPage1RouteIndex,
"expected_client_progress", currentInitialTownProgress,
"reason", "wait_for_current_exe_page_controller_before_selected_actor_and_town_transition")
}
func (s *Service) handleCurrentInitialTownProgress(
session *gameSession,
progress uint32,
) error {
return s.sendCurrentInitialTownRoute(session, progress, true, false)
}
// resumeCurrentInitialTownRouteAfterReturnSelect owns the selector re-entry
// boundary. The current client emits progress36 only on the first selector-page
// controller lifecycle. After a successful op7 module return it sends op8 and a
// new op4, but does not repeat op143. Resume the already-proved town transition
// after the new op4 ACK without inventing an op143 request or success response.
func (s *Service) resumeCurrentInitialTownRouteAfterReturnSelect(session *gameSession) error {
return s.sendCurrentInitialTownRoute(session, currentInitialTownProgress, false, true)
}
// resumeCurrentInitialTownRouteAfterSelectHeartbeat covers the current client
// path where a completed character reaches the town loading page but never
// emits the old op143/progress36 page-controller request. The heartbeat is not
// acknowledged as op143; it only lets the server resume the already armed town
// actor/transition stream that select-character prepared.
func (s *Service) resumeCurrentInitialTownRouteAfterSelectHeartbeat(session *gameSession, source string) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
session.townMu.Lock()
routeCharacterID := session.initialTownRouteCharacterID
routeStage := session.initialTownRouteStage
session.townMu.Unlock()
if routeCharacterID == 0 ||
routeCharacterID != session.selectedCharacterID ||
routeStage < currentInitialTownRouteArmed ||
routeStage >= currentInitialTownRoutePlayerStateSent {
return nil
}
s.logGameEvent(session, "game-initial-town-route-heartbeat-resume",
"source", source,
"char_id", session.selectedCharacterID,
"route_char_id", routeCharacterID,
"route_stage", routeStage,
"progress", currentInitialTownProgress,
"op143_ack_sent", false,
"reason", "client_reached_town_loading_page_without_progress36")
return s.sendCurrentInitialTownRoute(session, currentInitialTownProgress, false, false)
}
func (s *Service) sendCurrentInitialTownRoute(
session *gameSession,
progress uint32,
acknowledgeProgress bool,
requireReturnSelectReentry bool,
) error {
if session == nil {
return nil
}
session.dungeon.mu.Lock()
hasDungeonRuntime := session.dungeon.runtime != nil
session.dungeon.mu.Unlock()
if hasDungeonRuntime {
s.logGameEvent(session, "game-initial-town-route-blocked",
"char_id", session.selectedCharacterID,
"progress", progress,
"reason", "active_dungeon_runtime_owns_op143")
return nil
}
session.townMu.Lock()
defer session.townMu.Unlock()
if requireReturnSelectReentry && !session.returnSelectTownReentryPending {
s.logGameEvent(session, "game-initial-town-route-deferred",
"char_id", session.selectedCharacterID,
"route_char_id", session.initialTownRouteCharacterID,
"progress", progress,
"route_stage", session.initialTownRouteStage,
"reason", "return_select_reentry_not_owned_by_server_op7")
return nil
}
if progress != currentInitialTownProgress ||
session.initialTownRouteStage < currentInitialTownRouteArmed ||
session.initialTownRouteCharacterID == 0 ||
session.initialTownRouteCharacterID != session.selectedCharacterID {
s.logGameEvent(session, "game-initial-town-route-deferred",
"char_id", session.selectedCharacterID,
"route_char_id", session.initialTownRouteCharacterID,
"progress", progress,
"route_stage", session.initialTownRouteStage,
"reason", "progress36_not_owned_by_completed_select_route")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID, characterName, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
if characterID != session.initialTownRouteCharacterID || !hasCharacter ||
selectedCharacterStartsInTutorial(character, hasCharacter) {
s.logGameEvent(session, "game-initial-town-route-blocked",
"char_id", session.selectedCharacterID,
"route_char_id", session.initialTownRouteCharacterID,
"loaded_char_id", characterID,
"has_character", hasCharacter,
"tutorial_completed", hasPersistedDungeonTutorialCompletion(character),
"progress", progress,
"reason", "fresh_character_state_does_not_own_completed_select_route")
return nil
}
// Character-list login uses the Seria-room owner for completed characters.
// A tutorial-pending character returned above never reaches this branch, so
// its first in-dungeon position remains client/PVF owned by that dungeon map.
character, townID, areaID, row, mapPath, err := s.currentCharacterListLoginTransition(ctx, session, characterID, character)
if err != nil {
s.logGameEvent(session, "game-initial-town-route-blocked",
"char_id", session.selectedCharacterID,
"progress", progress,
"reason", "persisted_character_list_login_location_unavailable",
"error", err)
return nil
}
transitionBody, err := buildCurrentSceneTransitionBody(townID, areaID, []currentSceneTransitionRow{row})
if err != nil {
return err
}
// The cold-login selected actor must use the channel identity established by
// CHANNELINFO. The current client keeps the mode0/mode1 actor in that native
// channel context; context zero is reserved for compatibility-hook paths and
// must not be used for this initial town route.
ownerChannel := currentConnectionTownActorOwnerContext(session)
session.townActorOwnerChannel = ownerChannel
objectBody := s.buildCurrentSceneObjectListBodyForSessionInContext(
ctx,
session,
characterID,
characterName,
character,
hasCharacter,
ownerChannel,
)
adventureSummary := s.currentAccountAdventureGroupSummaryForPacket(ctx, session, character, hasCharacter)
adventureLevel := uint32(adventureSummary.ManageLevel)
// mode0's completion can immediately schedule the combat-power reader.
// Bind the selected actor with the authoritative equipment rows now,
// before the scene snapshot and any lengthy account/container projection.
mode1Body := s.buildCurrentActorBindingMode1BodyForSelectedWithEquipmentInContext(
ctx,
session,
character,
hasCharacter,
characterID,
adventureLevel,
ownerChannel,
true,
)
commitTransitionEarly := requireReturnSelectReentry || session.returnSelectTownReentryPending
var earlyActorDisplayBody []byte
if commitTransitionEarly {
earlyActorDisplayBody = buildCurrentInitialTownOp9Body(
session,
currentSceneActorObjectKey(characterID),
character,
hasCharacter,
characterName,
)
}
acceptedEvent := "game-initial-town-route-progress36-accepted"
sequence := "op143_ack_then_op376_then_msg2_mode0_then_complete_equipment_mode1_then_scene_snapshot_and_full_player_state_then_typed_op24"
trigger := "client_op143_progress36"
if requireReturnSelectReentry {
acceptedEvent = "game-initial-town-route-return-select-reentry-accepted"
sequence = "op376_then_msg2_mode0_then_complete_equipment_mode1_then_scene_snapshot_and_full_player_state_then_typed_op24"
trigger = "server_owned_op7_then_client_op8_then_client_op4"
}
s.logGameEvent(session, acceptedEvent,
"char_id", characterID,
"progress", progress,
"route_stage", session.initialTownRouteStage,
"trigger", trigger,
"op143_ack_sent", acknowledgeProgress,
"tutorial_completed", hasPersistedDungeonTutorialCompletion(character),
"town_id", townID,
"area_id", areaID,
"map_path", mapPath,
"position_x", row.Value1,
"position_y", row.Value2,
"direction", row.Value3,
"area_state", row.Value4,
"adventure_total_point", adventureSummary.TotalPoint,
"adventure_manage_level", adventureSummary.ManageLevel,
"owner_server", 0,
"owner_channel", ownerChannel,
"sequence", sequence,
"evidence_boundary", "current_exe_live_first_select_and_return_select_reentry_lifecycle")
// The current op143 success reader consumes success plus a reward count.
// A duplicate progress-36 request receives another response, but the
// one-shot selected-object packets below resume from their committed stage.
if acknowledgeProgress {
if err := s.sendGameUpperSuccess(
session,
uint16(dnfenum.CmdPacketChangeTutorialFlag),
[]byte{0},
); err != nil {
return err
}
}
policy := currentInitialTownRoutePolicy{
includeReportClientSpec: true,
includeSecondFullMode1: !commitTransitionEarly,
skipPrerequisiteMode1: true,
commitTransitionEarly: commitTransitionEarly,
earlyActorDisplayBody: earlyActorDisplayBody,
deferClearQuestListUntilLocationNotifications: true,
}
if session.initialTownRouteStage >= currentInitialTownRouteTransitionSent {
if session.initialTownRouteStage == currentInitialTownRouteTransitionSent {
if policy.commitTransitionEarly {
if err := s.sendCurrentInitialTownDungeonPermissionSnapshotLocked(
session,
"return_select_retry_after_op24_scene_commit",
); err != nil {
return err
}
if err := s.sendCurrentTownPlayerStateLocked(session, "return_select_resume_after_early_typed_op24", policy); err != nil {
return fmt.Errorf("resume return-select player state after early transition: %w", err)
}
} else {
if err := s.sendCurrentTownTransitionLocked(
session,
characterID,
townID,
areaID,
row,
transitionBody,
); err != nil {
return err
}
}
}
if policy.commitTransitionEarly {
if err := s.sendCurrentPostTownTransitionQuestSnapshotsLocked(
session,
"current_exe_return_select_retry_after_op24_scene_commit",
); err != nil {
return err
}
if err := s.sendCurrentExperienceConsumableBuffProjection(session, "return_select_retry_after_early_op24"); err != nil {
return fmt.Errorf("restore experience consumable HUD after resumed return-select transition: %w", err)
}
// 城镇增益药剂(史诗Buff药剂一族)走同一条"恢复"语义:重登/重选之后
// buff 图标与倒计时都要按角色身上还没过期的窗口补发,直到持续时间耗尽。
if err := s.sendCurrentStatChangePotionBuffProjection(session, "return_select_retry_after_early_op24"); err != nil {
return fmt.Errorf("restore stat-change potion HUD after resumed return-select transition: %w", err)
}
}
if err := s.sendCurrentInitialTownGuildStateProjectionLocked(
session,
"current_exe_initial_town_retry_after_op24_scene_commit",
); err != nil {
return err
}
s.publishCurrentTownPresence(
session,
character,
townID,
areaID,
row,
0,
mapPath,
"initial_town_completed_route_retry",
)
duplicateEvent := "game-initial-town-route-duplicate-ack-only"
if commitTransitionEarly {
session.returnSelectTownReentryPending = false
duplicateEvent = "game-initial-town-route-return-select-reentry-duplicate-skipped"
}
s.logGameEvent(session, duplicateEvent,
"char_id", characterID,
"progress", progress,
"route_stage", session.initialTownRouteStage)
return nil
}
if err := s.sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
session,
characterID,
townID,
areaID,
row,
objectBody,
mode1Body,
transitionBody,
policy,
); err != nil {
return err
}
s.publishCurrentTownPresence(
session,
character,
townID,
areaID,
row,
0,
mapPath,
"initial_town_actor_route_complete",
)
if commitTransitionEarly {
session.returnSelectTownReentryPending = false
}
s.logGameEvent(session, "game-initial-town-route-transition-sent",
"char_id", characterID,
"progress", progress,
"town_id", townID,
"area_id", areaID,
"map_path", mapPath,
"row_count", 1,
"trigger", trigger,
"op143_ack_sent", acknowledgeProgress,
"route_stage", session.initialTownRouteStage,
"next_owner", "current_client_scene_request_or_finish_loading")
return nil
}