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