Files
90CNServer/server/internal/services/dnfbridge/story_digest.go
T
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

287 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}