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

461 lines
18 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"sort"
"strings"
darkcombo "longheng.io/server/internal/modules/dnf/darkknightcombo"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/skillcmd"
)
type currentSceneSkillInfoProjection struct {
body []byte
characterID string
job byte
learnedCount int
learnedSkillIDs []int64
activeSlots []int
quickSkillIDs []uint16
activeTree byte
}
func (s *Service) buildCurrentSceneSkillInfoProjection(
session *gameSession,
ctx context.Context,
character dnfrepo.CharacterRecord,
source string,
) (currentSceneSkillInfoProjection, bool, error) {
if s == nil || session == nil {
return currentSceneSkillInfoProjection{}, false, nil
}
repos, ok := s.repositoryGroup()
if !ok || repos.Skill == nil {
s.logGameEvent(session, "game-main-skill-info-deferred", "source", source, "reason", "skill_repository_missing")
return currentSceneSkillInfoProjection{}, false, nil
}
job, ok := characterJobByte(character)
if !ok {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("current scene skill info has invalid job %q", character.Job)
}
record, backfilled, err := s.loadOrBackfillCurrentSceneSkillRecord(ctx, repos, character, job)
if err != nil {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("load current scene skill info: %w", err)
}
s.initialSkillsMu.Lock()
catalog := s.skillCatalog
s.initialSkillsMu.Unlock()
if catalog == nil {
s.logGameEvent(session, "game-main-skill-info-deferred", "source", source, "reason", "skill_catalog_missing")
return currentSceneSkillInfoProjection{}, false, nil
}
layoutBackfilled := false
for tree := 0; tree < currentSkillInfoTreeCount; tree++ {
var changed bool
record, _, changed, err = s.ensureCurrentSceneSkillLayout(ctx, repos, character, job, tree, catalog, record)
if err != nil {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("build current scene skill layout %d: %w", tree, err)
}
layoutBackfilled = layoutBackfilled || changed
}
if backfilled {
s.logGameEvent(session, "game-main-skill-info-backfilled",
"source", source,
"character_id", character.CharacterID,
"job", job,
"learned_count", len(record.Skills),
"layout_count", len(record.Layouts),
"record_source", "runtime_pvf_character_initial_value_skill")
}
if layoutBackfilled {
s.logGameEvent(session, "game-main-skill-layout-backfilled",
"source", source,
"character_id", character.CharacterID,
"job", job,
"layout_count", len(record.Layouts),
"layout_source", "job_scoped_pvf_initial_skill_layout")
}
record = dnfrepo.CloneSkill(record)
if record.Layouts == nil {
record.Layouts = make(map[int]dnfrepo.SkillLayout)
}
var darkKnightComboPages []darkcombo.Page
if job == darkcombo.Job {
pages, comboErr := s.loadDarkKnightComboPages(ctx, repos, character, catalog, record)
if comboErr != nil {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("load dark knight combo projection: %w", comboErr)
}
darkKnightComboPages = pages
}
activeTree, err := currentSceneActiveSkillTree(character)
if err != nil {
return currentSceneSkillInfoProjection{}, false, err
}
body, activeSlots, err := buildCurrentSceneSkillInfoBodyWithDarkKnightCombo(record, record.Layouts, activeTree, darkKnightComboPages)
if err != nil {
return currentSceneSkillInfoProjection{}, false, err
}
activeState, found := dnfrepo.SkillTree(record, int(activeTree))
if !found {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("current scene active skill tree %d is missing", activeTree)
}
learnedSkillIDs := make([]int64, 0, len(activeState.Skills))
for skillID, state := range activeState.Skills {
if state.Enabled && state.Level > 0 {
learnedSkillIDs = append(learnedSkillIDs, skillID)
}
}
sort.Slice(learnedSkillIDs, func(left, right int) bool {
return learnedSkillIDs[left] < learnedSkillIDs[right]
})
quickSkillIDs := make([]uint16, 0, len(activeSlots))
activeLayout := record.Layouts[int(activeTree)]
for _, slot := range activeSlots {
quickSkillIDs = append(quickSkillIDs, activeLayout[slot])
}
return currentSceneSkillInfoProjection{
body: append([]byte(nil), body...),
characterID: character.CharacterID,
job: job,
learnedCount: len(activeState.Skills),
learnedSkillIDs: append([]int64(nil), learnedSkillIDs...),
activeSlots: append([]int(nil), activeSlots...),
quickSkillIDs: append([]uint16(nil), quickSkillIDs...),
activeTree: activeTree,
}, true, nil
}
func (s *Service) sendCurrentSceneSkillInfoProjection(
session *gameSession,
projection currentSceneSkillInfoProjection,
source string,
) error {
s.logGameEvent(session, "game-main-skill-info-send",
"source", source,
"character_id", projection.characterID,
"job", projection.job,
"msg_id", currentSkillInfoMsgID,
"classification", 0,
"body_len", len(projection.body),
"learned_count", projection.learnedCount,
"learned_skill_ids", projection.learnedSkillIDs,
"active_quick_slots", projection.activeSlots,
"active_quick_skill_ids", projection.quickSkillIDs,
"tree_count", currentSkillInfoTreeCount,
"active_tree", projection.activeTree,
"wire_level_encoding", "persisted_level_direct",
"body_source", "current_exe_sub_1D6E240_length_prefixed_DNFPB_skillinfo")
if err := s.sendGameUpperRawClass(session, currentSkillInfoMsgID, projection.body, 0); err != nil {
return err
}
return nil
}
func (s *Service) sendCurrentSceneSkillInfo(session *gameSession, ctx context.Context, character dnfrepo.CharacterRecord, source string) error {
projection, ok, err := s.buildCurrentSceneSkillInfoProjection(session, ctx, character, source)
if err != nil || !ok {
return err
}
return s.sendCurrentSceneSkillInfoProjectionWithTreeRestore(session, projection, source)
}
func currentSceneActiveSkillTree(character dnfrepo.CharacterRecord) (byte, error) {
value, ok := character.Stats["skill_tree_index"]
if !ok {
return currentSkillInfoTreeIndex, nil
}
if value < 0 || value >= currentSkillInfoTreeCount {
return 0, fmt.Errorf("character %s has invalid active skill tree %d", character.CharacterID, value)
}
return byte(value), nil
}
// buildCurrentSceneSkillInfoRuntimeBody is the single formatter boundary for
// already-committed skill snapshots outside the normal scene-build path.
// Dark Knight roots require their persisted page children in op19 field 4;
// using the generic builder after a level/quest mutation would recreate an
// ordinary manager and silently drop the ASDFGH combat chains.
func (s *Service) buildCurrentSceneSkillInfoRuntimeBody(
ctx context.Context,
character dnfrepo.CharacterRecord,
record dnfrepo.SkillRecord,
job byte,
activeTree byte,
) ([]byte, []int, error) {
if job != darkcombo.Job {
return buildCurrentSceneSkillInfoBody(record, record.Layouts, activeTree)
}
repos, ok := s.repositoryGroup()
if !ok || repos.Settings == nil {
return nil, nil, fmt.Errorf("dark knight combo runtime repository is unavailable")
}
s.initialSkillsMu.Lock()
catalog := s.skillCatalog
s.initialSkillsMu.Unlock()
if catalog == nil {
return nil, nil, fmt.Errorf("dark knight combo runtime catalog is unavailable")
}
pages, err := s.loadDarkKnightComboPages(ctx, repos, character, catalog, record)
if err != nil {
return nil, nil, fmt.Errorf("load dark knight combo runtime pages: %w", err)
}
return buildCurrentSceneSkillInfoBodyWithDarkKnightCombo(record, record.Layouts, activeTree, pages)
}
func (s *Service) sendCurrentSceneSkillInfoProjectionWithTreeRestore(
session *gameSession,
projection currentSceneSkillInfoProjection,
source string,
) error {
if err := s.sendCurrentSceneSkillInfoProjectionWithTreeRestoreOnly(session, projection, source); err != nil {
return err
}
if projection.job != darkcombo.Job {
return nil
}
if session == nil || projection.characterID != selectedCharacterIDString(session) {
return fmt.Errorf(
"dark knight combo projection character mismatch after op19: projection=%s selected=%s",
projection.characterID,
selectedCharacterIDString(session),
)
}
// Stable actor refreshes replace the current client's ordinary skill
// manager, so restore the independent combo pages after the op19/tree
// projection. The native op510 auto-combination transaction is the one
// deliberate exception: it must use the *Only helper below because the
// client itself writes the new page with op508 after the reset ACK.
return s.sendSelectedDarkKnightComboInitialization(session, source+"_after_op19_before_hud_commit")
}
// sendCurrentSceneSkillInfoProjectionWithTreeRestoreOnly writes only the
// ordinary skill snapshot and optional active-tree ACK. Keeping this boundary
// explicit prevents an unrelated class-0/op447 projection from being inserted
// into the native op510 -> op508 auto-combination transaction.
func (s *Service) sendCurrentSceneSkillInfoProjectionWithTreeRestoreOnly(
session *gameSession,
projection currentSceneSkillInfoProjection,
source string,
) error {
if err := s.sendCurrentSceneSkillInfoProjection(session, projection, source); err != nil {
return err
}
if projection.activeTree != currentSkillInfoTreeIndex {
body, err := skillcmd.BuildChangeAnotherSkillTreeSuccess(skillcmd.TreeSwitchMutationResult{
Current: projection.activeTree ^ 1,
Target: projection.activeTree,
})
if err != nil {
return err
}
s.logGameEvent(session, "game-main-skill-tree-selection-restore-send",
"source", source,
"character_id", projection.characterID,
"msg_id", uint16(dnfenum.CmdPacketChangeAnotherSkillTree),
"classification", 1,
"active_tree", projection.activeTree,
"body_source", "current_exe_sub_1D0C8F0_success_target_after_final_op19")
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketChangeAnotherSkillTree), body, 1); err != nil {
return err
}
}
return nil
}
// sendInitialTownSkillInfoBeforeTransition installs the selected actor's skill
// projection exactly once after the final town actor bind and before op24.
// Retain the projection only as a route invariant until op24 commits; sending
// the same snapshot again after op24 makes the current client treat
// already-owned qualification skills as newly learned skills.
func (s *Service) sendInitialTownSkillInfoBeforeTransition(
session *gameSession,
ctx context.Context,
character dnfrepo.CharacterRecord,
source string,
) error {
projection, ready, err := s.buildCurrentSceneSkillInfoProjection(session, ctx, character, source)
if err != nil || !ready {
return err
}
if err := s.sendInitialTownSkillInfoProjectionBeforeTransition(session, projection, source); err != nil {
return err
}
// 修炼道场:装完普通技能快照后再推道场自己的两页(op1230)+ 模式(op1229)。
return s.sendCurrentPVPDojoSkillsBeforeTransition(ctx, session, character)
}
// sendCurrentSceneSkillInfoProjectionBeforeEquipment creates the selected
// character's native skill manager before an equipment-bearing mode1 packet.
// On a cross-character selection the current client otherwise validates the
// new character's weapon against the previous character's mastery skills and
// queues an automatic unequip request. This preload deliberately sends only
// op19: mode1 can replace the manager, so the ordinary post-mode1 op19 remains
// the authoritative skill/quickbar snapshot and owns op1428 tree restoration.
func (s *Service) sendCurrentSceneSkillInfoProjectionBeforeEquipment(
session *gameSession,
projection currentSceneSkillInfoProjection,
source string,
) error {
s.logGameEvent(session, "game-main-skill-info-equipment-validation-preload-send",
"source", source,
"character_id", projection.characterID,
"job", projection.job,
"msg_id", currentSkillInfoMsgID,
"classification", 0,
"body_len", len(projection.body),
"learned_count", projection.learnedCount,
"tree_count", currentSkillInfoTreeCount,
"body_source", "same_selected_character_op19_as_post_mode1_without_buff_ui_side_effects")
return s.sendGameUpperRawClass(session, currentSkillInfoMsgID, projection.body, 0)
}
// sendInitialTownSkillInfoProjectionBeforeTransition installs an already-built
// projection at the accepted pre-op24 boundary. The initial-town route builds
// the projection once so the weapon-validation preload and this authoritative
// write carry the identical selected-character snapshot.
func (s *Service) sendInitialTownSkillInfoProjectionBeforeTransition(
session *gameSession,
projection currentSceneSkillInfoProjection,
source string,
) error {
if err := s.sendCurrentSceneSkillInfoProjectionWithTreeRestore(session, projection, source); err != nil {
return err
}
session.initialTownSkillInfo = projection
session.initialTownSkillInfoPrepared = true
session.initialTownSkillInfoSent = true
s.logGameEvent(session, "game-initial-town-skill-info-installed-before-op24",
"source", source,
"character_id", projection.characterID,
"learned_count", projection.learnedCount,
"active_quick_slots", projection.activeSlots)
return nil
}
// commitPreparedInitialTownSkillInfoAfterTransition consumes the initial-town
// route invariant after op24. The authoritative op19 was already installed
// after the final actor bind but before op24, so no second op19 is sent here. The current
// client completes that typed scene transition by rebuilding the Dark Knight
// combo manager, so the independent native op447 pages are restored here.
// Movement and dungeon-return routes retain their independent post-transition
// skill reload.
func (s *Service) commitPreparedInitialTownSkillInfoAfterTransition(
session *gameSession,
source string,
) error {
if session == nil || !session.initialTownSkillInfoPrepared {
return nil
}
projection := session.initialTownSkillInfo
if projection.characterID == "" || len(projection.body) == 0 {
return fmt.Errorf("initial town skill projection is incomplete after op24")
}
if projection.characterID != fmt.Sprintf("%d", session.selectedCharacterID) {
return fmt.Errorf(
"initial town skill projection character mismatch: projection=%s selected=%d",
projection.characterID,
session.selectedCharacterID,
)
}
if err := s.sendSelectedDarkKnightComboInitialization(session, source+"_after_typed_op24"); err != nil {
return err
}
session.initialTownSkillInfoPrepared = false
session.initialTownSkillInfoSent = true
session.initialTownSkillInfo = currentSceneSkillInfoProjection{}
s.logGameEvent(session, "game-initial-town-skill-info-committed-without-op24-replay",
"source", source,
"character_id", projection.characterID,
"learned_count", projection.learnedCount,
"active_quick_slots", projection.activeSlots,
"reason", "current_client_treats_duplicate_initial_op19_as_new_skill_acquisition_and_requires_native_dark_knight_restore_after_typed_transition")
return nil
}
func (s *Service) selectedActorCurrentSceneSkillInfoProjection(
session *gameSession,
source string,
) (currentSceneSkillInfoProjection, bool, error) {
if session == nil || session.selectedCharacterID == 0 {
return currentSceneSkillInfoProjection{}, false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("selected actor skill projection could not load character repository")
}
characterID := fmt.Sprintf("%d", session.selectedCharacterID)
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("load selected actor %s for skill projection: %w", characterID, err)
}
if !found {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("selected actor %s for skill projection was not found", characterID)
}
if accountID := strings.TrimSpace(s.accountIDForSession(session)); accountID != "" &&
accountID != strings.TrimSpace(character.AccountID) {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("selected actor %s skill projection owner mismatch", characterID)
}
projection, ready, err := s.buildCurrentSceneSkillInfoProjection(
session,
ctx,
character,
source,
)
if err != nil {
return currentSceneSkillInfoProjection{}, false, err
}
if !ready {
return currentSceneSkillInfoProjection{}, false, fmt.Errorf("selected actor %s skill projection is unavailable", characterID)
}
return projection, true, nil
}
// sendSelectedActorCurrentSceneSkillInfo reloads the selected character before
// projecting op19. Existing-actor mode0/mode1 rebinds can replace the native
// skill manager, so callers must invoke this only after the final actor rebind,
// never from the DLL or before the repository-backed appearance packets.
func (s *Service) sendSelectedActorCurrentSceneSkillInfo(session *gameSession, source string) error {
// 修炼道场:在道场里 op19 会把演员技能清空,所以改推道场自己的两页(op1231)。
dojoSkills, dojoErr := s.currentPVPDojoSkillsActive(session)
if dojoErr != nil {
return dojoErr
}
if dojoSkills {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repos, _ := s.repositoryGroup()
record, err := skillcmd.PrepareDojoSkills(ctx, repos, fmt.Sprint(session.selectedCharacterID))
if err != nil {
return err
}
body, _, err := buildCurrentSceneSkillInfoBody(record, record.Layouts, 0)
if err != nil {
return err
}
// sub_10982E0 updates the dojo manager; ordinary op19 clears actor skills.
return s.sendGameUpperRawClass(session, 1231, body, 0)
}
projection, ready, err := s.selectedActorCurrentSceneSkillInfoProjection(session, source)
if err != nil || !ready {
return err
}
return s.sendCurrentSceneSkillInfoProjectionWithTreeRestore(session, projection, source)
}
// sendSelectedActorCurrentSceneSkillInfoOnly is reserved for protocol flows
// that own their subsequent Dark Knight combo mutation. In particular op510
// cleans ordinary shortcut duplicates, installs this op19 projection, receives
// its ACK, and then lets the client submit the generated page through op508.
func (s *Service) sendSelectedActorCurrentSceneSkillInfoOnly(session *gameSession, source string) error {
projection, ready, err := s.selectedActorCurrentSceneSkillInfoProjection(session, source)
if err != nil || !ready {
return err
}
return s.sendCurrentSceneSkillInfoProjectionWithTreeRestoreOnly(session, projection, source)
}