服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
const (
|
|
currentDungeonTutorialFinalPrefix = byte(0)
|
|
currentDungeonTutorialPreFinal = uint32(30)
|
|
currentDungeonTutorialFinalProgress = uint32(31)
|
|
currentDungeonTutorialFinalCommit = byte(1)
|
|
currentDungeonTutorialCompleteFlag = int64(1)
|
|
currentDungeonTutorialCompletedKey = "tutorial_completed"
|
|
// Live current-EXE evidence shows that the persisted-reentry op33/op143/op24
|
|
// fast-exit leaves the client on the dungeon page and suppresses op45. Keep
|
|
// the durable marker, but do not emit this unproven transition chain.
|
|
currentDungeonCompletedReentryFastExitEnabled = false
|
|
)
|
|
|
|
// handleDungeonTutorialFlag acknowledges the current-EXE tutorial checkpoint
|
|
// request and closes the final exit handshake. Progress 30 is an ordinary
|
|
// request/response checkpoint: it receives the same empty-reward ACK as the
|
|
// old server but never persists completion, sends op115, or returns to town.
|
|
func (s *Service) handleDungeonTutorialFlag(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
request, err := dungeoncmd.DecodeChangeTutorialFlagRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"body_len", len(body),
|
|
"expected_body_len", dungeoncmd.ChangeTutorialFlagRequestSize,
|
|
"reason", "current_exe_op143_plaintext_boundary_mismatch",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-request-decoded",
|
|
"body_len", len(body),
|
|
"prefix", request.Prefix,
|
|
"progress", request.Progress,
|
|
"commit_flag", request.CommitFlag,
|
|
"body_source", "current_exe_sub_33BDAF0_exact_six_byte_writer")
|
|
if request.Prefix != currentDungeonTutorialFinalPrefix ||
|
|
request.CommitFlag != currentDungeonTutorialFinalCommit {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-deferred",
|
|
"prefix", request.Prefix,
|
|
"progress", request.Progress,
|
|
"commit_flag", request.CommitFlag,
|
|
"reason", "unsupported_tutorial_checkpoint_envelope")
|
|
return nil
|
|
}
|
|
if request.Progress == currentDungeonTutorialPreFinal {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-op143-ack-send",
|
|
"char_id", session.selectedCharacterID,
|
|
"progress", request.Progress,
|
|
"msg_id", uint16(dnfenum.CmdPacketChangeTutorialFlag),
|
|
"classification", 1,
|
|
"success", 1,
|
|
"reward_count", 0,
|
|
"body_len", 2,
|
|
"completion_persisted", false,
|
|
"town_transition", false,
|
|
"body_source", "current_exe_sub_33C4A20_and_old_86jp_request_response_checkpoint")
|
|
if err := s.sendGameUpperSuccess(
|
|
session,
|
|
uint16(dnfenum.CmdPacketChangeTutorialFlag),
|
|
[]byte{0},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if request.Progress == currentDungeonTutorialRewardProgress {
|
|
return s.handleCurrentDungeonTutorialReward(session, request.Progress)
|
|
}
|
|
if request.Progress == currentInitialTownProgress {
|
|
return s.handleCurrentInitialTownProgress(session, request.Progress)
|
|
}
|
|
if request.Progress != currentDungeonTutorialFinalProgress {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-deferred",
|
|
"prefix", request.Prefix,
|
|
"progress", request.Progress,
|
|
"commit_flag", request.CommitFlag,
|
|
"reason", "unsupported_tutorial_checkpoint_progress")
|
|
return nil
|
|
}
|
|
selectedCharacterID := session.selectedCharacterID
|
|
if selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"progress", request.Progress,
|
|
"reason", "selected_character_missing")
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, selectedCharacterID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"progress", request.Progress,
|
|
"reason", "persisted_town_transition_unavailable",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
session.dungeon.mu.Lock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || runtime.Room == nil {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"progress", request.Progress,
|
|
"reason", "active_dungeon_runtime_missing")
|
|
return nil
|
|
}
|
|
if session.selectedCharacterID != selectedCharacterID || !dungeonRuntimeOwnsCharacter(runtime, selectedCharacterID) {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"request_char_id", selectedCharacterID,
|
|
"runtime_char_id", runtime.Character.CharacterID,
|
|
"progress", request.Progress,
|
|
"reason", "active_dungeon_runtime_character_owner_mismatch")
|
|
return nil
|
|
}
|
|
snapshot := runtime.Session.Snapshot()
|
|
persistedReentryCompletion := snapshot.Run.Status == worldmap.DungeonRunActive &&
|
|
runtime.tutorialCompletedReentry && runtime.tutorialCompletionPersisted &&
|
|
runtime.tutorialCompletedReentryExitSent && isPVFTutorialDungeonScene(runtime, snapshot.Scene)
|
|
if !persistedReentryCompletion {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_status", snapshot.Run.Status,
|
|
"boss_die_check_accepted", runtime.bossDieCheckAccepted,
|
|
"tutorial_completed_reentry", runtime.tutorialCompletedReentry,
|
|
"tutorial_completed_reentry_exit_sent", runtime.tutorialCompletedReentryExitSent,
|
|
"reason", "progress31_is_only_valid_for_persisted_completed_reentry")
|
|
return nil
|
|
}
|
|
if !runtime.tutorialFinalFlagAckSent {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-flag-op143-ack-send",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"msg_id", uint16(dnfenum.CmdPacketChangeTutorialFlag),
|
|
"classification", 1,
|
|
"success", 1,
|
|
"reward_count", 0,
|
|
"body_len", 2,
|
|
"body_source", "current_exe_sub_33C4A20_success_then_u8_reward_count")
|
|
if err := s.sendGameUpperSuccess(
|
|
session,
|
|
uint16(dnfenum.CmdPacketChangeTutorialFlag),
|
|
[]byte{0},
|
|
); err != nil {
|
|
session.dungeon.mu.Unlock()
|
|
return err
|
|
}
|
|
runtime.tutorialFinalFlagAckSent = true
|
|
}
|
|
|
|
session.dungeon.mu.Unlock()
|
|
return s.sendCurrentActiveDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
uint16(dnfenum.CmdPacketChangeTutorialFlag),
|
|
"persisted_completed_tutorial_reentry_op143_after_finish_loading_op33",
|
|
)
|
|
}
|
|
|
|
// sendCompletedTutorialReentryExit conditionally starts the current-EXE
|
|
// tutorial exit handshake after FINISH_LOADING has bound the selected actor
|
|
// and page. Static gate evidence supports this order; completed-marker reentry
|
|
// still requires a fresh live acceptance before it is considered proven.
|
|
// Sending a non-empty completion index in the select ACK would switch pages
|
|
// synchronously before that owner exists and crashes the current EXE.
|
|
func (s *Service) sendCompletedTutorialReentryExit(session *gameSession, source string) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || runtime.Room == nil ||
|
|
!runtime.tutorialCompletedReentry || !runtime.tutorialCompletionPersisted {
|
|
return nil
|
|
}
|
|
if !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-reentry-exit-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"runtime_char_id", runtime.Character.CharacterID,
|
|
"reason", "active_dungeon_runtime_character_owner_mismatch")
|
|
return nil
|
|
}
|
|
if runtime.tutorialCompletedReentryExitSent {
|
|
return nil
|
|
}
|
|
if !currentDungeonCompletedReentryFastExitEnabled {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-reentry-exit-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"source", source,
|
|
"tutorial_completion_persisted", runtime.tutorialCompletionPersisted,
|
|
"reason", "live_current_exe_op24_return_failed_and_suppressed_next_room_op45")
|
|
return nil
|
|
}
|
|
if !session.postStartMapPlayerStateSent || !session.currentFinishLoadingStateSent ||
|
|
!session.currentFinishLoadingCompletionSent ||
|
|
!session.postFinishLoadingPlayerStateSent {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-reentry-exit-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"post_start_map_state_sent", session.postStartMapPlayerStateSent,
|
|
"finish_loading_state_sent", session.currentFinishLoadingStateSent,
|
|
"finish_loading_completion_sent", session.currentFinishLoadingCompletionSent,
|
|
"post_finish_loading_state_sent", session.postFinishLoadingPlayerStateSent,
|
|
"reason", "selected_actor_and_page_not_fully_bound")
|
|
return nil
|
|
}
|
|
snapshot := runtime.Session.Snapshot()
|
|
if snapshot.Run.Status != worldmap.DungeonRunActive ||
|
|
!isPVFTutorialDungeonScene(runtime, snapshot.Scene) {
|
|
s.logGameEvent(session, "game-dungeon-tutorial-reentry-exit-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_status", snapshot.Run.Status,
|
|
"reason", "owned_active_pvf_tutorial_scene_missing")
|
|
return nil
|
|
}
|
|
body := []byte{currentDungeonTutorialExitReason}
|
|
s.logGameEvent(session, "game-dungeon-tutorial-reentry-exit-op33-send",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", snapshot.Run.Current.String(),
|
|
"msg_id", currentDungeonTutorialExitMsgID,
|
|
"classification", 0,
|
|
"reason_code", currentDungeonTutorialExitReason,
|
|
"source", source,
|
|
"body_source", "persisted_completion_after_current_exe_finish_loading_actor_page_binding")
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonTutorialExitMsgID, body, 0); err != nil {
|
|
return err
|
|
}
|
|
runtime.tutorialCompletedReentryExitSent = true
|
|
return nil
|
|
}
|
|
|
|
// persistCurrentDungeonTutorialCompletion makes the final tutorial handshake
|
|
// survive reconnects. It updates the formal mirrored character-stat field; it
|
|
// never fabricates tutorial indexes or stores the transient progress value 31.
|
|
func (s *Service) persistCurrentDungeonTutorialCompletion(
|
|
ctx context.Context,
|
|
characterID uint16,
|
|
) (int64, error) {
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return 0, dungeoncmd.ErrOwnerUnavailable
|
|
}
|
|
owner, err := dnfdungeon.NewOwner(repositories)
|
|
if err != nil {
|
|
return 0, dungeoncmd.ErrOwnerUnavailable
|
|
}
|
|
result, err := owner.CompleteTutorial(ctx, dnfdungeon.TutorialCompletionCommand{
|
|
CharacterID: strconv.FormatUint(uint64(characterID), 10),
|
|
CompletedKey: currentDungeonTutorialCompletedKey,
|
|
Completed: currentDungeonTutorialCompleteFlag,
|
|
NextLogin: map[string]int64{
|
|
"town_id": newCharacterInitialTownID,
|
|
"area_id": newCharacterInitialAreaID,
|
|
"pos_x": newCharacterInitialPosX,
|
|
"pos_y": newCharacterInitialPosY,
|
|
"direction": newCharacterInitialDirection,
|
|
"area_state": newCharacterInitialAreaState,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return result.Previous, err
|
|
}
|
|
return result.Previous, nil
|
|
}
|
|
|
|
// applyCurrentDungeonTutorialCompletionStats mirrors the durable tutorial
|
|
// decision into an already-owned character aggregate. Persistence belongs to
|
|
// the dungeon owner.
|
|
func applyCurrentDungeonTutorialCompletionStats(character *dnfrepo.CharacterRecord) bool {
|
|
if character == nil {
|
|
return false
|
|
}
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
values := map[string]int64{
|
|
currentDungeonTutorialCompletedKey: currentDungeonTutorialCompleteFlag,
|
|
"town_id": newCharacterInitialTownID,
|
|
"area_id": newCharacterInitialAreaID,
|
|
"pos_x": newCharacterInitialPosX,
|
|
"pos_y": newCharacterInitialPosY,
|
|
"direction": newCharacterInitialDirection,
|
|
"area_state": newCharacterInitialAreaState,
|
|
}
|
|
changed := false
|
|
for key, value := range values {
|
|
if current, ok := character.Stats[key]; !ok || current != value {
|
|
character.Stats[key] = value
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func hasPersistedDungeonTutorialCompletion(character dnfrepo.CharacterRecord) bool {
|
|
return character.Stats != nil &&
|
|
character.Stats[currentDungeonTutorialCompletedKey] == currentDungeonTutorialCompleteFlag
|
|
}
|