DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,286 @@
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
}