服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
864 lines
36 KiB
Go
864 lines
36 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"fmt"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
)
|
||
|
||
const currentTownActorSceneSnapshotMsgID uint16 = 0x0320
|
||
|
||
// buildCurrentTownActorSceneSnapshotBody matches the current EXE's scene
|
||
// snapshot reader (sub_1D6C6F0):
|
||
//
|
||
// u8 actor_count, actor_count * { u16 existing_actor_key, u8 child_count }
|
||
//
|
||
// The reader first resolves every actor key through sub_2699A30, then builds
|
||
// its scene-side bindings. A selected character has no server-owned children
|
||
// at town entry, so child_count is zero. The key is session data, never a
|
||
// captured DOVE key.
|
||
func buildCurrentTownActorSceneSnapshotBody(actorKey uint16) []byte {
|
||
body := make([]byte, 4)
|
||
body[0] = 1
|
||
binary.LittleEndian.PutUint16(body[1:3], actorKey)
|
||
return body
|
||
}
|
||
|
||
// sendCurrentInitialTownActorSceneSnapshotLocked completes the mode0-created
|
||
// selected actor's scene binding before full player state/location packets.
|
||
// The caller holds session.townMu.
|
||
func (s *Service) sendCurrentInitialTownActorSceneSnapshotLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
) error {
|
||
if session == nil || characterID == 0 ||
|
||
session.initialTownRouteCharacterID != characterID ||
|
||
session.initialTownRouteStage < currentInitialTownRouteActorBound ||
|
||
session.initialTownActorSceneSnapshotSent {
|
||
return nil
|
||
}
|
||
actorKey := currentSceneActorObjectKey(characterID)
|
||
body := buildCurrentTownActorSceneSnapshotBody(actorKey)
|
||
if err := s.sendCurrentSceneFixedClass0Packet(
|
||
session,
|
||
currentTownActorSceneSnapshotMsgID,
|
||
body,
|
||
"initial_town_actor_scene_snapshot_current_exe_sub_1D6C6F0",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownActorSceneSnapshotSent = true
|
||
s.logGameEvent(session, "game-initial-town-actor-scene-snapshot-sent",
|
||
"char_id", characterID,
|
||
"actor_object_key", actorKey,
|
||
"child_count", 0,
|
||
"sequence", "mode0_actor_created_then_noti0x320_scene_snapshot_then_full_player_state")
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentInitialTownActorRoutePacketsLocked owns completed character-login
|
||
// ordering. Unlike a dungeon return, it finishes the full actor state before
|
||
// op24 becomes the final transition commit. The caller must hold session.townMu.
|
||
func (s *Service) sendCurrentInitialTownActorRoutePacketsLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
transitionBody []byte,
|
||
) error {
|
||
return s.sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
|
||
session,
|
||
characterID,
|
||
townID,
|
||
areaID,
|
||
row,
|
||
objectBody,
|
||
mode1Body,
|
||
transitionBody,
|
||
currentInitialTownRoutePolicy{
|
||
includeReportClientSpec: true,
|
||
// The selected actor is created by mode0. The complete mode1 is delayed
|
||
// until the ordinary skill snapshot is ready; state-only expert-job
|
||
// projection follows op19 so existing skills are never interpreted as a
|
||
// newly acquired qualification on login.
|
||
includeSecondFullMode1: true,
|
||
skipPrerequisiteMode1: true,
|
||
deferClearQuestListUntilLocationNotifications: true,
|
||
},
|
||
)
|
||
}
|
||
|
||
func (s *Service) sendCurrentChannelReconnectTownActorRoutePacketsLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
transitionBody []byte,
|
||
) error {
|
||
return s.sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
|
||
session,
|
||
characterID,
|
||
townID,
|
||
areaID,
|
||
row,
|
||
objectBody,
|
||
mode1Body,
|
||
transitionBody,
|
||
currentInitialTownRoutePolicy{
|
||
includeReportClientSpec: true,
|
||
includeSecondFullMode1: true,
|
||
skipPrerequisiteMode1: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
func (s *Service) sendCurrentInitialTownActorRoutePacketsWithOptionsLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
transitionBody []byte,
|
||
policy currentInitialTownRoutePolicy,
|
||
) error {
|
||
prerequisiteMode1Body := mode1Body
|
||
if policy.skipPrerequisiteMode1 {
|
||
prerequisiteMode1Body = nil
|
||
}
|
||
if err := s.sendCurrentTownActorPrerequisitesLocked(session, characterID, objectBody, prerequisiteMode1Body); err != nil {
|
||
return err
|
||
}
|
||
if policy.commitTransitionEarly {
|
||
objectKey := currentSceneActorObjectKey(characterID)
|
||
if err := s.sendCurrentAdventureInfoPushFromAccount(
|
||
session,
|
||
objectKey,
|
||
"return_select_after_mode0_before_single_full_mode1",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
// Cross-character reselect keeps the previous actor's skill manager
|
||
// until op19 is consumed. The equipment-bearing mode1 below immediately
|
||
// validates worn equipment, so preload the selected character's op19
|
||
// first; otherwise weapon-mastery jobs queue a client-authored unequip.
|
||
// sendCurrentTownPlayerStateLocked replays the identical projection at
|
||
// its established final boundary.
|
||
if err := s.sendCurrentReturnSelectSkillInfoPreloadLocked(
|
||
session,
|
||
characterID,
|
||
"return_select_before_single_full_mode1_weapon_validation",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1Body, 0); err != nil {
|
||
return err
|
||
}
|
||
if err := captureCurrentPartyActorProjection(session, characterID, objectBody, mode1Body); err != nil {
|
||
s.logGameEvent(session, "game-party-actor-full-projection-refresh-rejected",
|
||
"source", "return_select_single_full_mode1_before_early_op24",
|
||
"char_id", characterID,
|
||
"character_generation", session.characterGeneration,
|
||
"mode0_len", len(objectBody),
|
||
"mode1_len", len(mode1Body),
|
||
"reason", err)
|
||
}
|
||
// The collection-box projection was published with the mode0 actor above.
|
||
// Refresh the selected box again now that the complete equipment-bearing
|
||
// mode1 has bound the native actor the collect-box stat owner belongs to.
|
||
if err := s.replayCollectboxSelectedRefreshAfterMode1(
|
||
context.Background(),
|
||
session,
|
||
"return_select_after_single_full_mode1",
|
||
); err != nil {
|
||
s.logGameEvent(session, "game-collectbox-selection-refresh-deferred",
|
||
"char_id", characterID,
|
||
"source", "return_select_after_single_full_mode1",
|
||
"reason", err)
|
||
}
|
||
}
|
||
if err := s.sendCurrentInitialTownActorSceneSnapshotLocked(session, characterID); err != nil {
|
||
return fmt.Errorf("initialize selected town actor scene snapshot: %w", err)
|
||
}
|
||
if len(policy.earlyActorDisplayBody) != 0 {
|
||
if err := s.sendCurrentInitialTownActorDisplayAndPlacementLocked(
|
||
session,
|
||
"return_select_after_op800_before_location",
|
||
policy.earlyActorDisplayBody,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Publish the final channel-owned town/area as soon as the actor scene
|
||
// binding exists.
|
||
if err := s.sendCurrentInitialTownLocationNotificationsLocked(session, characterID, townID, areaID, row); err != nil {
|
||
return fmt.Errorf("initialize selected town location notifications: %w", err)
|
||
}
|
||
if policy.commitTransitionEarly {
|
||
if policy.deferClearQuestListUntilLocationNotifications {
|
||
if err := s.sendCurrentInitialTownClearQuestListAfterLocationLocked(
|
||
session,
|
||
"return_select_after_location_notifications_before_early_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("initialize return-select clear quest list before early transition: %w", err)
|
||
}
|
||
}
|
||
if err := s.sendCurrentSpendTimeInitialStateOnce(
|
||
session,
|
||
"return_select_before_early_op24_activity_state",
|
||
); err != nil {
|
||
return fmt.Errorf("initialize return-select activity state before early transition: %w", err)
|
||
}
|
||
if err := s.sendCurrentTownTransitionBeforePlayerStateLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentInitialTownDungeonPermissionSnapshotLocked(
|
||
session,
|
||
"return_select_after_op24_scene_commit",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if policy.commitTransitionEarly || session.initialTownRouteStage < currentInitialTownRoutePlayerStatePrepared {
|
||
policy.actorMode0Body = append([]byte(nil), objectBody...)
|
||
if err := s.sendCurrentTownPlayerStateLocked(
|
||
session,
|
||
"initial_town_before_final_transition",
|
||
policy,
|
||
); err != nil {
|
||
return fmt.Errorf("initialize selected town actor before transition: %w", err)
|
||
}
|
||
}
|
||
if policy.deferClearQuestListUntilLocationNotifications && !policy.commitTransitionEarly {
|
||
if err := s.sendCurrentInitialTownClearQuestListAfterLocationLocked(
|
||
session,
|
||
"initial_town_after_location_notifications_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("initialize selected town clear quest list after location notifications: %w", err)
|
||
}
|
||
}
|
||
if err := s.sendCurrentSpendTimeInitialStateOnce(
|
||
session,
|
||
"initial_town_before_op24_activity_state",
|
||
); err != nil {
|
||
return fmt.Errorf("initialize selected town activity state before transition: %w", err)
|
||
}
|
||
if err := s.sendCurrentTownTransitionLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
|
||
return err
|
||
}
|
||
// Cold login and return-select commit op24 before their final town task
|
||
// projection. Channel reconnect already owns a separate verified post-op24
|
||
// refresh and must not receive a duplicate sequence.
|
||
if !session.channelReconnect {
|
||
if err := s.sendCurrentPostTownTransitionQuestSnapshotsLocked(
|
||
session,
|
||
"current_exe_initial_town_after_op24_scene_commit",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Character selection rebuilds the native scene owner. Restore the
|
||
// repository-backed class-0 JOIN_GUILD_INFO notification only after typed
|
||
// op24 and the route-specific quest/skill refreshes; this notification is
|
||
// what writes the client's primary current-guild ID.
|
||
if err := s.sendCurrentInitialTownGuildStateProjectionLocked(
|
||
session,
|
||
"current_exe_initial_town_after_op24_scene_commit",
|
||
); err != nil {
|
||
return fmt.Errorf("restore current guild after initial town transition: %w", err)
|
||
}
|
||
// Relog/reselect has no local op44 activation event. Restore the durable
|
||
// effect item after op24 and its post-transition skill replay.
|
||
if err := s.sendCurrentExperienceConsumableBuffProjection(session, "initial_town_after_op24"); err != nil {
|
||
return fmt.Errorf("restore experience consumable HUD after initial town transition: %w", err)
|
||
}
|
||
// Same reason, for the durable item cooltime list: the client's cooldown gate
|
||
// is process-local memory, so a 小退/重上 made a 1-day-cooldown consumable look
|
||
// ready again even though the server still refuses it (live 2026-09-17).
|
||
if err := s.sendCurrentStatChangePotionBuffProjection(session, "initial_town_after_op24"); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentItemCooltimeProjection(session, "initial_town_after_op24"); err != nil {
|
||
return fmt.Errorf("restore item cooltime list after initial town transition: %w", err)
|
||
}
|
||
// 商城"限购已买满"的商品列表(类 0 通知 860):客户端的限购记忆只在会话内存里,
|
||
// 不重放的话小退/大退/重启服务端之后那些商品又会出现在商城里(现场 2026-09-18:
|
||
// "买完从商品列表消失、重上又出现")。计数是持久化的,所以重放结果稳定。
|
||
// 纯展示:失败只记日志,不影响回城/选角流程。
|
||
if err := s.sendCurrentCeraShopBuyRestrictProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-buy-restrict-send-failed", "source", "initial_town_after_op24", "err", err)
|
||
}
|
||
// 商城三个钱包数字(点券 / 代币券 / 欢乐代币券,类 0 通知 53):客户端没有
|
||
// "请求钱包"的上行包,纯靠服务端推。不推的话选角进城后商城那三个数字是 0,
|
||
// 玩家会以为代币券没到账。
|
||
//
|
||
// 只挂在选角进城这一处,不挂回城:钱包变化只有两个来源——购买(tera_shop_purchase.go
|
||
// 已经在成功后推了真实值)和代币券兑换(兑换点自己推)。回城重放属于投机性多发,
|
||
// 只会污染无关的地城返回包序列,没有实际收益。
|
||
// 纯展示:失败只记日志,不影响选角/进城流程。
|
||
if err := s.sendCurrentCeraShopWalletProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-wallet-send-failed", "source", "initial_town_after_op24", "err", err)
|
||
}
|
||
// 多买多送"已购买次数"(类 0 通知 1035,包体 = 一个 u32 绝对次数)。同样没有上行
|
||
// 请求包:客户端窗口不会自己算次数,只在开窗时读服务端推来的值。活动没配置时
|
||
// 这个函数直接返回,不发包。
|
||
if err := s.sendCurrentCeraShopBuyCountGiftProjection(context.Background(), session, "initial_town_after_op24"); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-buycount-gift-send-failed", "source", "initial_town_after_op24", "err", err)
|
||
}
|
||
// Same reason, for the weakness health cap: selecting a character rebuilds
|
||
// the local player at a healthy 100, and the subtype0 stamina byte alone
|
||
// does not restore the cap. Without this republish a weakened character
|
||
// came back from character selection at full health -- which looked like
|
||
// re-selecting cured the penalty, even though the stored level never moved.
|
||
if err := s.sendCurrentWeaknessConditionRefresh(session, "initial_town_after_op24"); err != nil {
|
||
return fmt.Errorf("restore weakness health cap after initial town transition: %w", err)
|
||
}
|
||
// 公会地下城 progression. The client keeps this table per session and gates
|
||
// entry on it locally, so it has to be republished whenever the player is
|
||
// rebuilt. See guild_dungeon.go.
|
||
s.sendCurrentGuildDungeonState(session, "initial_town_after_op24")
|
||
// The endpoint handshake is connection-scoped and completes before the
|
||
// selected-town route. Never follow op24 with another class0/op1: it would
|
||
// arm a fresh client watchdog after the one allowed class1/op1 success.
|
||
s.logGameEvent(session, "game-initial-town-route-endpoint-preserved",
|
||
"char_id", characterID,
|
||
"town_id", townID,
|
||
"area_id", areaID,
|
||
"route_stage", session.initialTownRouteStage,
|
||
"resident_notice_sent", session.currentChannelResidentNoticeSent,
|
||
"town_actor_owner_channel", session.townActorOwnerChannel,
|
||
"endpoint_policy", "channelinfo_then_single_requested_class1_op1")
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentReturnSelectSkillInfoPreloadLocked installs the selected
|
||
// character's op19 before the single equipment-bearing mode1 of the
|
||
// return-select route. It mirrors the cold-login weapon-validation preload and
|
||
// is a no-op when the skill projection is not ready yet.
|
||
func (s *Service) sendCurrentReturnSelectSkillInfoPreloadLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
source string,
|
||
) error {
|
||
if s == nil || session == nil || characterID == 0 {
|
||
return nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
charID, _, character, hasCharacter := s.selectedCharacterForEnter(ctx, session)
|
||
if !hasCharacter || charID != characterID {
|
||
return nil
|
||
}
|
||
projection, ready, err := s.buildCurrentSceneSkillInfoProjection(session, ctx, character, source)
|
||
if err != nil {
|
||
return fmt.Errorf("build return-select skill preload: %w", err)
|
||
}
|
||
if !ready {
|
||
return nil
|
||
}
|
||
return s.sendCurrentSceneSkillInfoProjectionBeforeEquipment(session, projection, source)
|
||
}
|
||
|
||
func (s *Service) sendCurrentInitialTownDungeonPermissionSnapshotLocked(
|
||
session *gameSession,
|
||
source string,
|
||
) error {
|
||
if session == nil || session.initialTownDungeonPermissionSent {
|
||
return nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
if err := s.sendCurrentDungeonPermissionSnapshot(ctx, session, source); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownDungeonPermissionSent = true
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentTownTransitionBeforePlayerStateLocked commits the return-select
|
||
// map without completing the still-pending player-state stream.
|
||
func (s *Service) sendCurrentTownTransitionBeforePlayerStateLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
transitionBody []byte,
|
||
) error {
|
||
if session == nil || characterID == 0 ||
|
||
session.initialTownRouteCharacterID != characterID ||
|
||
session.initialTownRouteStage < currentInitialTownRouteActorBound ||
|
||
session.initialTownRouteStage >= currentInitialTownRouteTransitionSent {
|
||
return nil
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, transitionBody, 0); err != nil {
|
||
return err
|
||
}
|
||
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, townID, areaID, row)
|
||
session.initialTownRouteStage = currentInitialTownRouteTransitionSent
|
||
return nil
|
||
}
|
||
|
||
// waitCurrentInitialTownSceneCommitLocked preserves the former visual-cover
|
||
// boundary as a zero-duration test seam. The caller continues directly into
|
||
// the route-specific scene finalizers.
|
||
func (s *Service) waitCurrentInitialTownSceneCommitLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
) {
|
||
if s == nil || session == nil || s.initialTownEntryWait == nil {
|
||
return
|
||
}
|
||
s.logGameEvent(session, "game-initial-town-client-loading-cover-disabled",
|
||
"char_id", characterID,
|
||
"duration", currentInitialTownEntryDelay.String(),
|
||
"owner", "none",
|
||
"server_packet_stream_blocked", false)
|
||
s.initialTownEntryWait(currentInitialTownEntryDelay)
|
||
}
|
||
|
||
// sendCurrentTownActorRoutePacketsLocked preserves the accepted completed
|
||
// dungeon-return ordering. The result-page actor remains addressable by its
|
||
// stable key, so the mode0 row is an event-suppressed state refresh; the
|
||
// following equipment-bearing mode1 rebinds its durable equipment graph. The
|
||
// caller must hold session.townMu.
|
||
func (s *Service) sendCurrentTownActorRoutePacketsLocked(
|
||
ctx context.Context,
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
transitionBody []byte,
|
||
refreshCompletedCreatureState bool,
|
||
seedCompletedQuestState bool,
|
||
) error {
|
||
if err := s.sendCurrentTownActorPrerequisitesLocked(session, characterID, objectBody, nil); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentCompletedTownReturnPlayerStateLocked(session, characterID, objectBody, mode1Body); err != nil {
|
||
return err
|
||
}
|
||
if refreshCompletedCreatureState &&
|
||
session.initialTownRouteStage < currentInitialTownRouteCreatureStateSent {
|
||
if err := s.sendSelectedCreatureCompletedTownReturnStateAfterMode1(
|
||
session,
|
||
"completed_dungeon_return_after_mode1_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return creature state: %w", err)
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteCreatureStateSent
|
||
}
|
||
if _, err := s.sendSelectedGuardianShieldDeckInfo(
|
||
session,
|
||
"completed_dungeon_return_guardian_shield_deck_after_mode1_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return guardian shield deck: %w", err)
|
||
}
|
||
if _, err := s.sendSelectedGuardianShieldTownAppearanceRefreshOnce(
|
||
session,
|
||
"completed_dungeon_return_guardian_shield_appearance_after_deck_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return guardian shield appearance: %w", err)
|
||
}
|
||
if _, err := s.sendSelectedChatEmoticonCatalog(
|
||
session,
|
||
"completed_dungeon_return_chat_emoticon_catalog_after_mode1_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return chat emoticon catalog: %w", err)
|
||
}
|
||
// Completed dungeon return deliberately bypasses the heavy cold-login
|
||
// container bootstrap. Rehydrate the durable slot-32 row after its first
|
||
// equipment-bearing mode1 and before the town transition.
|
||
if !session.initialTownGuardianGemRefreshSent {
|
||
if err := s.sendSelectedGuardianGemWornMedalRefresh(
|
||
session,
|
||
"completed_dungeon_return_after_mode1_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return guardian-gem medal: %w", err)
|
||
}
|
||
session.initialTownGuardianGemRefreshSent = true
|
||
}
|
||
// Same boundary, same reason: the worn title's raw random-enchant bytes are
|
||
// not carried by the create rows either (random_title_enchant_refresh.go).
|
||
if err := s.sendSelectedWornTitleEnchantRowRefresh(
|
||
session,
|
||
"completed_dungeon_return_after_mode1_before_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return worn title enchant row: %w", err)
|
||
}
|
||
// The current client rebuilds NPC task markers from op21/op574 only after
|
||
// the selected actor exists. Completed dungeon return bypasses the full
|
||
// cold-login initialization, so publish the repository/PVF snapshots at
|
||
// the same safe pre-op24 boundary instead of leaving the task manual stale.
|
||
// The completed bitmap (op356) must seed BEFORE these snapshots: op574 only
|
||
// upserts active rows, so an earlier bitmap is what drops the just-finished
|
||
// dungeon child from the HUD and keeps the map [quest condition] gates
|
||
// addressable when the typed op24 below rebuilds the town scene. This is the
|
||
// reference completed-return order (mode1 -> op356 -> op21/op574 -> op22/op23
|
||
// -> op24) and differs from the dungeon-selector prefix which still emits
|
||
// op22/op23 before op356.
|
||
//
|
||
// An interrupted (abandon) return is different: no dungeon quest was finished,
|
||
// so the client still owns the pre-entry quest state it left the town with.
|
||
// Replaying op356/op21/op574 here rebuilds that state from the repository and
|
||
// drops the client's cached [quest condition] town gates. The reference
|
||
// active-giveup route (sendCurrentDungeonReturnToTownLocked) publishes only
|
||
// mode0/mode1 then op22/op23/op24 and leaves the quest state untouched, so
|
||
// this caller passes seedCompletedQuestState=false and only the location pair
|
||
// is emitted below.
|
||
if seedCompletedQuestState {
|
||
if !session.initialTownLocationNotificationsSent {
|
||
if err := s.sendCurrentDungeonClearQuestSeedLocked(
|
||
ctx,
|
||
session,
|
||
characterID,
|
||
"current_exe_dungeon_return_town",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendCurrentInitialTownQuestSnapshotsLocked(
|
||
session,
|
||
"completed_dungeon_return_after_mode1_before_typed_op24",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if !session.initialTownLocationNotificationsSent {
|
||
if err := s.sendCurrentTownLocationNotifications(
|
||
session,
|
||
characterID,
|
||
townID,
|
||
areaID,
|
||
row,
|
||
"completed_dungeon_return_pre_op24",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownLocationNotificationsSent = true
|
||
}
|
||
if err := s.sendCurrentTownTransitionLocked(session, characterID, townID, areaID, row, transitionBody); err != nil {
|
||
return err
|
||
}
|
||
// 回城和登录一样会重建客户端场景:HUD 上的持久投影(经验药水 / 公会状态+属性 buff /
|
||
// 属性药水 / 物品冷却)都要重放。公会那条尤其不能只补 buff —— 客户端的公会状态
|
||
// (guild ID、徽章 aura)也在重建中被清掉,只重发 buff 会出现"两个公会属性只回来一个"
|
||
//(2026-10-08 现场:448 基础 +120 → 只回 +60)。
|
||
if err := s.restoreCurrentSceneHudProjections(session, "dungeon_return_town_after_op24"); err != nil {
|
||
return fmt.Errorf("restore scene HUD projections after dungeon return: %w", err)
|
||
}
|
||
// 婚房状态(家具/情侣树)是结婚戒指上"婚房属性"的唯一数据源,场景重建后必须补发,
|
||
// 否则回城之后那枚戒指既不显示也不生效(只能靠小退重进婚房救回来)。
|
||
s.restoreCurrentSceneWeddingRoomState(session, characterID, "dungeon_return_town_after_op24")
|
||
if err := s.sendCurrentCeraShopBuyRestrictProjection(context.Background(), session, "dungeon_return_town_after_op24"); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-buy-restrict-send-failed", "source", "dungeon_return_town_after_op24", "err", err)
|
||
}
|
||
// 这里刻意不推商城钱包(见 initial_town 处的说明):回城的包序列已经被大量
|
||
// 地城返回测试逐步钉死,钱包值在这条路径上不会变化,多发只会惊动无关用例。
|
||
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentCompletedTownReturnPlayerStateLocked rebuilds only the selected
|
||
// actor data that is absent after a completed-dungeon scene change, then writes
|
||
// the first equipment-bearing mode1. The dungeon scene already owns the skill
|
||
// manager, and replaying its full op19 snapshot here makes the current client
|
||
// interpret existing auxiliary-profession skills as a newly acquired
|
||
// qualification. Each successful native frame advances the route cursor so a
|
||
// transport failure retries only the remaining suffix. The caller must hold
|
||
// session.townMu.
|
||
func (s *Service) sendCurrentCompletedTownReturnPlayerStateLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
) error {
|
||
if session == nil || characterID == 0 ||
|
||
session.initialTownRouteCharacterID != characterID ||
|
||
session.initialTownRouteStage < currentInitialTownRouteActorBound {
|
||
return nil
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteSkillStateSent {
|
||
// A completed return keeps the learned-skill manager from the dungeon
|
||
// generation. Native 86JP's return route likewise does not send op19.
|
||
// Replaying it after a new town mode0 is not an initialization packet on
|
||
// this 90CN client: it opens the expert-job qualification dialog.
|
||
session.initialTownSkillInfoPrepared = false
|
||
session.initialTownSkillInfoSent = true
|
||
session.initialTownSkillInfo = currentSceneSkillInfoProjection{}
|
||
session.initialTownRouteStage = currentInitialTownRouteSkillStateSent
|
||
s.logGameEvent(session, "game-dungeon-completed-town-skill-projection-suppressed",
|
||
"char_id", characterID,
|
||
"reason", "dungeon_owned_skill_manager_must_not_replay_op19_as_a_qualification_notification")
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteEquipmentMode1Sent {
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
uint16(dnfenum.CmdPacketSetUDPIPPort),
|
||
mode1Body,
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := captureCurrentPartyActorProjection(session, characterID, objectBody, mode1Body); err != nil {
|
||
s.logGameEvent(session, "game-party-actor-owner-projection-rejected",
|
||
"char_id", characterID,
|
||
"character_generation", session.characterGeneration,
|
||
"mode0_len", len(objectBody),
|
||
"mode1_len", len(mode1Body),
|
||
"reason", err)
|
||
}
|
||
// Same boundary as the return-select route: the town-side op1027
|
||
// projection is published between mode0 and this mode1, so the selected
|
||
// box is refreshed once more against the final actor.
|
||
if err := s.replayCollectboxSelectedRefreshAfterMode1(
|
||
context.Background(),
|
||
session,
|
||
"completed_dungeon_return_after_full_mode1",
|
||
); err != nil {
|
||
s.logGameEvent(session, "game-collectbox-selection-refresh-deferred",
|
||
"char_id", characterID,
|
||
"source", "completed_dungeon_return_after_full_mode1",
|
||
"reason", err)
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteEquipmentMode1Sent
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteSkinAppearanceRefreshSent {
|
||
// The initial mode0 carries the correct skin row, but the subsequent
|
||
// complete mode1 initializes slot 8's detail object after that paper-doll
|
||
// pass. Reapply the complete mode0 table only when the skin row is
|
||
// occupied. This is the native existing-actor incremental refresh: its
|
||
// actor-state event is suppressed, so it cannot replay qualification UI.
|
||
if skinItemID, found := currentSceneObjectAppearanceSlotItemID(objectBody, 8); found &&
|
||
skinItemID != currentActorMode0AppearanceEmptyItem {
|
||
if err := s.sendSelectedActorMode0AppearanceRefresh(
|
||
session,
|
||
"completed_dungeon_return_skin_avatar",
|
||
"mode0_generation_then_mode1_then_event_suppressed_mode0_skin_avatar_refresh_before_op559",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return skin avatar appearance: %w", err)
|
||
}
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteSkinAppearanceRefreshSent
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteBuffRegistrationSent {
|
||
// op24 creates the final town scene feature used by NoPack's op559
|
||
// calculator. Advance the retry cursor here, but let the transition writer
|
||
// send the actual registration immediately after op24. Keeping an explicit
|
||
// pending bit makes a failed post-op24 write resume without replaying op24.
|
||
session.completedTownReturnBuffRegistrationPending = true
|
||
session.initialTownRouteStage = currentInitialTownRouteBuffRegistrationSent
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteProfessionStateSent {
|
||
// Completed return deliberately retains the dungeon skill manager and does
|
||
// not replay op19. Restoring the durable expert-job projection here is
|
||
// therefore only safe while it differs from the exact op205 body this
|
||
// client already accepted: on this 90CN client op205 is an
|
||
// acquisition/mutation notification, so replaying an unchanged
|
||
// profession after a scene rebuild opens the "副职业资格认证" banner and
|
||
// the one-shot "您学会新技能了" message. A real state change still
|
||
// publishes; BUFF registration stays deferred until the final typed op24
|
||
// scene commit below.
|
||
if err := s.sendCurrentExpertJobInfoForSceneRebuildForSelectedCharacter(
|
||
session,
|
||
"completed_dungeon_return_after_mode1_before_typed_op24",
|
||
); err != nil {
|
||
return fmt.Errorf("rehydrate completed-return expert-job state: %w", err)
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteProfessionStateSent
|
||
s.logGameEvent(session, "game-dungeon-completed-town-expert-job-state-restored",
|
||
"char_id", characterID,
|
||
"order", "event_suppressed_mode0_mode1_retained_skills_then_changed_state_only_op205",
|
||
"qualification_packets_replayed", false)
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRoutePlayerStatePrepared {
|
||
session.initialTownRouteStage = currentInitialTownRoutePlayerStatePrepared
|
||
session.selectedUserInfoRefreshSent = true
|
||
s.logGameEvent(session, "game-dungeon-town-route-player-state-ready",
|
||
"char_id", characterID,
|
||
"route_stage", session.initialTownRouteStage,
|
||
"sequence", "event_suppressed_mode0_equipment_mode1_optional_event_suppressed_skin_mode0_state_only_op205_typed_op24_then_op14_registered_chronicle_op559_buff_registration",
|
||
"reason", "completed_return_keeps_the_dungeon_skill_manager_and_restores_only_expert_job_state_without_acquisition")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendCurrentTownActorPrerequisitesLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
objectBody []byte,
|
||
mode1Body []byte,
|
||
) error {
|
||
if session == nil || characterID == 0 ||
|
||
session.initialTownRouteCharacterID != characterID ||
|
||
session.initialTownRouteStage < currentInitialTownRouteArmed {
|
||
return nil
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteActionTableSent {
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
uint16(dnfenum.CmdPacketPVPMissionHpPercent),
|
||
buildCurrentActionTableStateBody(),
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteActionTableSent
|
||
}
|
||
if session.returnSelectTownReentryPending &&
|
||
session.returnSelectPreviousCharacterID == characterID &&
|
||
!session.returnSelectRetainedActorRemoved {
|
||
if err := s.sendCurrentSceneFixedClass0Packet(
|
||
session,
|
||
currentTownUserLeaveMsgID,
|
||
buildTownUserLeaveBody(characterID),
|
||
"same_character_return_select_before_mode0",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
session.returnSelectRetainedActorRemoved = true
|
||
s.logGameEvent(session, "game-return-select-retained-actor-remove-send",
|
||
"char_id", characterID,
|
||
"msg_id", currentTownUserLeaveMsgID,
|
||
"reason", "same_character_reselect_removes_existing_town_actor_before_mode0")
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteObjectSent {
|
||
objectPacket := csharpSelectInitPacket{
|
||
class: 0,
|
||
msgID: uint16(dnfenum.CmdPacketSetUDPIPPort),
|
||
kind: csharpCurrentSceneObjectListKind,
|
||
}
|
||
if err := s.sendCSharpSelectInitPacket(session, objectPacket, objectBody); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRouteObjectSent
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteActorBound {
|
||
if len(mode1Body) > 0 {
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
uint16(dnfenum.CmdPacketSetUDPIPPort),
|
||
mode1Body,
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := captureCurrentPartyActorProjection(
|
||
session,
|
||
characterID,
|
||
objectBody,
|
||
mode1Body,
|
||
); err != nil {
|
||
// This cache is a party-admission prerequisite, not a login
|
||
// prerequisite. Keep the already-proved single-player town route
|
||
// unchanged and let party admission fail closed if an exact row pair
|
||
// was unavailable.
|
||
s.logGameEvent(session, "game-party-actor-owner-projection-rejected",
|
||
"char_id", characterID,
|
||
"character_generation", session.characterGeneration,
|
||
"mode0_len", len(objectBody),
|
||
"mode1_len", len(mode1Body),
|
||
"reason", err)
|
||
} else {
|
||
s.logGameEvent(session, "game-party-actor-owner-projection-captured",
|
||
"char_id", characterID,
|
||
"character_generation", session.characterGeneration,
|
||
"owner_server", objectBody[3],
|
||
"owner_channel", objectBody[4],
|
||
"mode0_len", len(objectBody),
|
||
"mode1_len", len(mode1Body),
|
||
"source", "successful_selected_actor_mode0_mode1_route")
|
||
}
|
||
} else {
|
||
s.logPacketEvent("game-initial-town-prerequisite-mode1-skipped",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"char_id", characterID,
|
||
"object_key", currentSceneActorObjectKey(characterID),
|
||
"reason", "route_policy_omits_actor_binding_mode1")
|
||
}
|
||
// A successfully created selected actor is a new native generation. The
|
||
// next post-skill op205 must not be suppressed by the preceding actor's
|
||
// character-ID cache; retries skip this reset because the route cursor has
|
||
// already advanced to ActorBound.
|
||
session.expertJobInfoCharacterID = 0
|
||
session.initialTownRouteStage = currentInitialTownRouteActorBound
|
||
}
|
||
// mode0 has created the selected actor. Replay the authoritative collection-
|
||
// box state at this generation boundary so the native owner applies its
|
||
// combat-stat bonuses before the scene snapshot/op24 transition.
|
||
if !session.collectboxSceneProjectionSent {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
err := s.sendCollectboxSceneInitializationStates(ctx, session)
|
||
cancel()
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-collectbox-scene-projection-deferred",
|
||
"char_id", characterID,
|
||
"source", "town_actor_after_mode1",
|
||
"reason", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendCurrentTownTransitionLocked(
|
||
session *gameSession,
|
||
characterID uint16,
|
||
townID byte,
|
||
areaID byte,
|
||
row currentSceneTransitionRow,
|
||
transitionBody []byte,
|
||
) error {
|
||
if session == nil || characterID == 0 ||
|
||
session.initialTownRouteCharacterID != characterID ||
|
||
session.initialTownRouteStage < currentInitialTownRouteActorBound {
|
||
return nil
|
||
}
|
||
if session.initialTownRouteStage < currentInitialTownRouteTransitionSent {
|
||
if err := s.sendGameUpperRawClass(session, currentSceneTransitionMsgID, transitionBody, 0); err != nil {
|
||
return err
|
||
}
|
||
setCurrentTownPositionSceneFromTransitionLocked(session, characterID, townID, areaID, row)
|
||
session.initialTownRouteStage = currentInitialTownRouteTransitionSent
|
||
}
|
||
if session.initialTownRouteStage == currentInitialTownRouteTransitionSent {
|
||
if session.completedTownReturnBuffRegistrationPending {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
err := s.sendSkillSwitchInventoryAllDataRefresh(
|
||
ctx,
|
||
session,
|
||
"completed_dungeon_return_after_typed_op24",
|
||
)
|
||
cancel()
|
||
if err != nil {
|
||
return fmt.Errorf("rehydrate completed-return BUFF registration after op24: %w", err)
|
||
}
|
||
session.completedTownReturnBuffRegistrationPending = false
|
||
}
|
||
// The selected actor's authoritative op19 was installed before mode1 and
|
||
// op24. Consume its route invariant without replaying it: a duplicate
|
||
// initial snapshot is interpreted as a newly learned qualification skill.
|
||
if err := s.commitPreparedInitialTownSkillInfoAfterTransition(
|
||
session,
|
||
"initial_town_after_typed_op24",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
session.initialTownRouteStage = currentInitialTownRoutePlayerStateSent
|
||
session.townSceneReadyCharacterID = characterID
|
||
}
|
||
return nil
|
||
}
|