DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user