package dnfbridge import ( "context" "encoding/binary" "math" "strconv" "strings" dnfproto "longheng.io/server/internal/modules/dnf/protocol" dnfquest "longheng.io/server/internal/modules/dnf/quest" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // The generated enum's name for 0x0562 belongs to an older protocol table. // Current NoPack statically registers this opcode to sub_11DBE40. const currentStoryDigestLastLevelMsgID uint16 = 0x0562 // buildCurrentStoryDigestLastLevelBody 构造 class0/op1378 的 body(raw u32 last_level)。 // // ★ 设计约定(按使用方要求):**章节动画只与「开启新区域的任务完成」挂钩**。 // // 客户端场景 finalizer 会拿「由 last_level 选出的小节」与「由当前等级选出的小节」 // 比较,不同就播一段章节过场。因此常规下发(登录 / 切场景 / 角色选择)以及 // **任何等级变化**(交任务、打怪、GM 改等级、经验书/升级券)都投影角色当前等级 // —— 两个比较值永远相同,客户端不会因为升级而在下一次进副本时自行播放过场。 // // 唯一允许制造差异的地方是开区任务完成:见 // quest_finish.go 的 sendCurrentFinishQuestStoryDigestBeforeSceneRefresh,它用 // sendCurrentStoryDigestLastLevelValue 显式下发该区域影片段的等级下界 - 1,并且 // 排在场景重建(op24)之前,让客户端在同一次场景构建里播出来。 // // 历史上曾尝试下发持久化 last_level(Server-90 的做法):开区能播,但登录/重登 // 也会补播摘要,不符合"只与开区挂钩"的要求,因此不再使用。 func buildCurrentStoryDigestLastLevelBody(character dnfrepo.CharacterRecord, hasCharacter bool) []byte { level := currentStoryDigestLastLevel(character, hasCharacter) // 章节过场已改由「服务端写触发文件 → 客户端补丁调用自己的播放器」实现 // (见 cutscene_trigger.go)。因此 op1378 这条路径不需要再制造任何差异: // 恒定下发角色当前等级,让客户端「由 last_level 选出的小节」与「由当前等级 // 选出的小节」永远相同 —— 登录 / 小退 / 重登 / GM 改等级都不会再触发播放。 if character.Level > 0 && uint64(character.Level) <= uint64(math.MaxUint32) { level = uint32(character.Level) } body := make([]byte, 4) binary.LittleEndian.PutUint32(body, level) return body } func currentStoryDigestLastLevel(character dnfrepo.CharacterRecord, hasCharacter bool) uint32 { if !hasCharacter || character.Stats == nil { return 0 } value := character.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] if value <= 0 { return 0 } if value > math.MaxUint32 { return math.MaxUint32 } return uint32(value) } func (s *Service) sendCurrentStoryDigestLastLevel(session *gameSession, character dnfrepo.CharacterRecord, hasCharacter bool, source string) error { body := buildCurrentStoryDigestLastLevelBody(character, hasCharacter) s.logGameEvent(session, "game-current-story-digest-last-level-send", "source", source, "char_id", session.selectedCharacterID, "msg_id", currentStoryDigestLastLevelMsgID, "classification", 0, "last_level", binary.LittleEndian.Uint32(body), "stored_story_digest_level", currentStoryDigestLastLevel(character, hasCharacter), "migration_version", characterStoryDigestMigrationVersion(character, hasCharacter), "body_len", len(body), "body_source", "current_exe_sub_11DBE40_raw_u32_before_scene_finalizer") return s.sendGameUpperRawClass(session, currentStoryDigestLastLevelMsgID, body, 0) } // sendCurrentStoryDigestLastLevelValue 下发一个**显式指定**的 last_level。 // // 用途:城镇区域开启时要把客户端推到「该区域过场影片所属的段落」,从而在段 // 结束处播放影片。按角色记录读只能拿到已持久化的值,无法表达这个意图。 func (s *Service) sendCurrentStoryDigestLastLevelValue(session *gameSession, level uint32, source string) error { body := make([]byte, 4) binary.LittleEndian.PutUint32(body, level) s.logGameEvent(session, "game-current-story-digest-last-level-send", "source", source, "char_id", session.selectedCharacterID, "msg_id", currentStoryDigestLastLevelMsgID, "classification", 0, "last_level", level, "body_len", len(body), "body_source", "explicit_town_area_cutscene_segment_level") return s.sendGameUpperRawClass(session, currentStoryDigestLastLevelMsgID, body, 0) } // sendCurrentStoryDigestLastLevelForLevelSync 把客户端内部的 last_level 拉平到 // 角色当前等级(class0/op1378,body = raw u32)。 // // 与 sendCurrentStoryDigestLastLevelValue 的区别只在日志语义:这条路径是 // 「等级变化后的基线同步」,不是开区任务的显式影片触发。客户端拿到相同的 // 等级后,场景 finalizer 里「last_level 所在段」与「当前等级所在段」一致, // 不会再因为升级而自动补播剧情摘要/章节动画。 func (s *Service) sendCurrentStoryDigestLastLevelForLevelSync( session *gameSession, level uint32, source string, ) error { if s == nil || session == nil || level == 0 { return nil } body := make([]byte, 4) binary.LittleEndian.PutUint32(body, level) s.logGameEvent(session, "game-current-story-digest-last-level-send", "source", source, "char_id", session.selectedCharacterID, "msg_id", currentStoryDigestLastLevelMsgID, "classification", 0, "last_level", level, "body_len", len(body), "body_source", "level_change_story_digest_sync") return s.sendGameUpperRawClass(session, currentStoryDigestLastLevelMsgID, body, 0) } // syncCurrentStoryDigestToCurrentLevel 读取已提交角色等级并把客户端 last_level // 拉平到该等级。用于开区任务播完影片后的收尾:开区路径故意制造过一次段差, // 如果不在场景就绪后补一次,客户端会在下一次进副本时把同一段影片再补播一次。 func (s *Service) syncCurrentStoryDigestToCurrentLevel(session *gameSession, source string) { if s == nil || session == nil || session.selectedCharacterID == 0 { return } repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil { return } ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() character, found, err := repositories.Character.Load(ctx, strconv.FormatUint(uint64(session.selectedCharacterID), 10)) if err != nil || !found || character.Level <= 0 { return } if err := s.sendCurrentStoryDigestLastLevelForLevelSync( session, uint32(character.Level), source); err != nil { s.logGameEvent(session, "game-current-story-digest-level-sync-failed", "source", source, "character_level", character.Level, "error", err) } } func characterStoryDigestMigrationVersion(character dnfrepo.CharacterRecord, hasCharacter bool) uint32 { if !hasCharacter || character.Stats == nil { return 0 } value := character.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] if value <= 0 { return 0 } if value > math.MaxUint32 { return math.MaxUint32 } return uint32(value) } func (s *Service) handleCurrentStoryDigestAccepted(session *gameSession, classification byte, body []byte) error { if classification != dnfproto.DefaultChannelClassification { s.logGameEvent(session, "game-current-story-digest-advance-blocked", "classification", classification, "body_len", len(body), "reason", "current_exe_op1445_command_class_mismatch") return nil } if len(body) != 0 { s.logGameEvent(session, "game-current-story-digest-advance-blocked", "classification", classification, "body_len", len(body), "reason", "current_exe_op1445_is_bodyless") return nil } if session == nil || session.selectedCharacterID == 0 { s.logGameEvent(session, "game-current-story-digest-advance-blocked", "body_len", len(body), "reason", "selected_character_missing") return nil } repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil { s.logGameEvent(session, "game-current-story-digest-advance-failed", "char_id", session.selectedCharacterID, "reason", "character_repository_missing") return dnfrepo.ErrCharacterStoryDigestAdvanceUnavailable } characterID := strconv.Itoa(int(session.selectedCharacterID)) ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() record, found, err := repositories.Character.Load(ctx, characterID) if err != nil { s.logGameEvent(session, "game-current-story-digest-character-load-failed", "char_id", session.selectedCharacterID, "error", err) return err } if !found || strings.TrimSpace(record.AccountID) != strings.TrimSpace(s.accountIDForSession(session)) { s.logGameEvent(session, "game-current-story-digest-advance-blocked", "char_id", session.selectedCharacterID, "found", found, "record_account_id", record.AccountID, "session_account_id", s.accountIDForSession(session), "reason", "selected_character_owner_mismatch") return nil } if record.Level <= 0 || uint64(record.Level) > uint64(math.MaxUint32) { s.logGameEvent(session, "game-current-story-digest-advance-blocked", "char_id", session.selectedCharacterID, "character_level", record.Level, "reason", "character_level_out_of_u32_range") return nil } previousLevel := currentStoryDigestLastLevel(record, true) previousVersion := characterStoryDigestMigrationVersion(record, true) if err := dnfrepo.AdvanceCharacterStoryDigest(ctx, repositories.Character, characterID, uint32(record.Level), dnfrepo.CurrentCharacterStoryDigestMigrationVersion); err != nil { s.logGameEvent(session, "game-current-story-digest-advance-failed", "char_id", session.selectedCharacterID, "character_level", record.Level, "previous_level", previousLevel, "previous_migration_version", previousVersion, "error", err) return err } s.logGameEvent(session, "game-current-story-digest-advanced", "char_id", session.selectedCharacterID, "previous_level", previousLevel, "current_level", record.Level, "previous_migration_version", previousVersion, "migration_version", dnfrepo.CurrentCharacterStoryDigestMigrationVersion, "wire_body_len", 0, "response", "none_current_exe_has_no_proven_ack", "persistence_boundary", "current_exe_accepted_playback_start_notification") return nil } // syncCurrentStoryDigestLevelForQuest 把持久化的 last_level 对齐到角色当前等级。 // // 为什么必须做(这就是之前那次修复的关键一步,日志事件 // game-current-story-digest-level-synced 可以证明): // 客户端用「由 last_level 选出的小节」和「由当前等级选出的小节」比较,不同就播 // 一段过场。如果等级变了而 last_level 没跟上,两者之间就出现"段差"——玩家下次 // 登录/小退重进时会被补播一段剧情回顾。等级变化本身不该产生段差,所以在这里 // 把它拉平;真正该播的章节动画只由开区任务那一次显式下发触发。 func (s *Service) syncCurrentStoryDigestLevelForQuest( session *gameSession, result dnfquest.FinishCommitResult, source string, ) error { if s == nil || session == nil || session.selectedCharacterID == 0 { return nil } level := result.PostCommitCharacter.Level if level <= 0 || uint64(level) > uint64(math.MaxUint32) { return nil } previousLevel := currentStoryDigestLastLevel(result.PostCommitCharacter, true) if previousLevel == uint32(level) { return nil } repositories, ok := s.repositoryGroup() if !ok || repositories.Character == nil { return nil } characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10) ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() if err := dnfrepo.AdvanceCharacterStoryDigest(ctx, repositories.Character, characterID, uint32(level), dnfrepo.CurrentCharacterStoryDigestMigrationVersion); err != nil { return err } s.logGameEvent(session, "game-current-story-digest-level-synced", "char_id", session.selectedCharacterID, "previous_last_level", previousLevel, "character_level", level, "source", source, "reason", "level_change_must_not_produce_a_story_segment_gap") return nil }