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,794 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
darkcombo "longheng.io/server/internal/modules/dnf/darkknightcombo"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfskill "longheng.io/server/internal/modules/dnf/skill"
|
||||
)
|
||||
|
||||
const (
|
||||
currentDarkKnightComboFailureCode byte = 4
|
||||
currentDarkKnightComboAutoWindow = 10 * time.Second
|
||||
// A current-client scene rebuild can emit an all-empty op508 roughly nine
|
||||
// seconds after the authoritative op447 projection. Keep enough margin to
|
||||
// reject that echo without blocking a later explicit editor operation.
|
||||
currentDarkKnightComboProjectionEchoWindow = 15 * time.Second
|
||||
|
||||
// These are class-0 S2C notifications in the current NoPack client. They
|
||||
// intentionally do not use dnfenum: the similarly numbered C2S command
|
||||
// entries have unrelated meanings in the public packet enum.
|
||||
currentDarkKnightComboInitializeMsgID uint16 = 447
|
||||
currentDarkKnightComboPreloadMsgID uint16 = 809
|
||||
)
|
||||
|
||||
type darkKnightComboSessionState struct {
|
||||
finalTownSceneMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
pendingCharacterID uint16
|
||||
pendingPage byte
|
||||
pendingDeadline time.Time
|
||||
projectionCharacterID uint16
|
||||
projectionGeneration uint64
|
||||
projectionAt time.Time
|
||||
projectionPages map[byte]darkKnightComboProjectionPageState
|
||||
finalTownSceneCharacterID uint16
|
||||
finalTownSceneGeneration uint64
|
||||
finalTownSceneInitialization bool
|
||||
}
|
||||
|
||||
type darkKnightComboProjectionPageState struct {
|
||||
canonicalHash [sha256.Size]byte
|
||||
canonicalNonEmpty bool
|
||||
acceptedNonEmpty bool
|
||||
}
|
||||
|
||||
type darkKnightComboSaveGuard struct {
|
||||
reject bool
|
||||
canonicalEcho bool
|
||||
generation uint64
|
||||
age time.Duration
|
||||
hash [sha256.Size]byte
|
||||
}
|
||||
|
||||
// reset drops every character- and actor-generation-scoped combo gate while
|
||||
// preserving the mutex values themselves. A game connection can return to the
|
||||
// selector and bind another character, so carrying an op510 token, an op447
|
||||
// projection hash, or a completed-town once key across that boundary would let
|
||||
// one character suppress or classify another character's native combo flow.
|
||||
func (state *darkKnightComboSessionState) reset() {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.finalTownSceneMu.Lock()
|
||||
defer state.finalTownSceneMu.Unlock()
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
state.pendingCharacterID = 0
|
||||
state.pendingPage = 0
|
||||
state.pendingDeadline = time.Time{}
|
||||
state.projectionCharacterID = 0
|
||||
state.projectionGeneration = 0
|
||||
state.projectionAt = time.Time{}
|
||||
state.projectionPages = nil
|
||||
state.finalTownSceneCharacterID = 0
|
||||
state.finalTownSceneGeneration = 0
|
||||
state.finalTownSceneInitialization = false
|
||||
}
|
||||
|
||||
func (state *darkKnightComboSessionState) arm(characterID uint16, page byte, deadline time.Time) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
state.pendingCharacterID = characterID
|
||||
state.pendingPage = page
|
||||
state.pendingDeadline = deadline
|
||||
}
|
||||
|
||||
func (state *darkKnightComboSessionState) consume(characterID uint16, page byte, now time.Time) bool {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.pendingDeadline.IsZero() {
|
||||
return false
|
||||
}
|
||||
if !now.Before(state.pendingDeadline) {
|
||||
state.pendingCharacterID = 0
|
||||
state.pendingPage = 0
|
||||
state.pendingDeadline = time.Time{}
|
||||
return false
|
||||
}
|
||||
if state.pendingCharacterID != characterID || state.pendingPage != page {
|
||||
return false
|
||||
}
|
||||
state.pendingCharacterID = 0
|
||||
state.pendingPage = 0
|
||||
state.pendingDeadline = time.Time{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (state *darkKnightComboSessionState) recordProjection(
|
||||
characterID uint16,
|
||||
pages []darkcombo.Page,
|
||||
now time.Time,
|
||||
) uint64 {
|
||||
projectionPages := make(map[byte]darkKnightComboProjectionPageState, len(pages))
|
||||
for _, page := range pages {
|
||||
block, err := darkcombo.EncodePageBlock(page)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
projectionPages[page.Index] = darkKnightComboProjectionPageState{
|
||||
canonicalHash: sha256.Sum256(block),
|
||||
canonicalNonEmpty: darkKnightComboPageNonEmpty(page),
|
||||
}
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
state.projectionGeneration++
|
||||
state.projectionCharacterID = characterID
|
||||
state.projectionAt = now
|
||||
state.projectionPages = projectionPages
|
||||
return state.projectionGeneration
|
||||
}
|
||||
|
||||
func (state *darkKnightComboSessionState) inspectSave(
|
||||
characterID uint16,
|
||||
page darkcombo.Page,
|
||||
now time.Time,
|
||||
matchedReset bool,
|
||||
) darkKnightComboSaveGuard {
|
||||
block, _ := darkcombo.EncodePageBlock(page)
|
||||
incomingHash := sha256.Sum256(block)
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
guard := darkKnightComboSaveGuard{generation: state.projectionGeneration}
|
||||
if state.projectionCharacterID != characterID || state.projectionAt.IsZero() {
|
||||
return guard
|
||||
}
|
||||
projected, found := state.projectionPages[page.Index]
|
||||
if !found {
|
||||
return guard
|
||||
}
|
||||
guard.hash = projected.canonicalHash
|
||||
guard.canonicalEcho = incomingHash == projected.canonicalHash
|
||||
if matchedReset || darkKnightComboPageNonEmpty(page) || !projected.canonicalNonEmpty || projected.acceptedNonEmpty {
|
||||
return guard
|
||||
}
|
||||
guard.age = now.Sub(state.projectionAt)
|
||||
guard.reject = guard.age >= 0 && guard.age < currentDarkKnightComboProjectionEchoWindow
|
||||
return guard
|
||||
}
|
||||
|
||||
func (state *darkKnightComboSessionState) markAcceptedNonEmpty(
|
||||
characterID uint16,
|
||||
page byte,
|
||||
generation uint64,
|
||||
) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.projectionCharacterID != characterID || state.projectionGeneration != generation {
|
||||
return
|
||||
}
|
||||
projected, found := state.projectionPages[page]
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
projected.acceptedNonEmpty = true
|
||||
state.projectionPages[page] = projected
|
||||
}
|
||||
|
||||
func darkKnightComboPageNonEmpty(page darkcombo.Page) bool {
|
||||
for _, root := range page.Roots {
|
||||
if len(root.Children) != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) currentDarkKnightComboNow() time.Time {
|
||||
now := s.gameplayNow().UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// loadDarkKnightComboPages lazily creates the missing independent combo
|
||||
// pages for preexisting job-9 characters. It never changes learned skills,
|
||||
// ordinary quick slots, or commands.
|
||||
func (s *Service) loadDarkKnightComboPages(
|
||||
ctx context.Context,
|
||||
repos dnfrepo.Group,
|
||||
character dnfrepo.CharacterRecord,
|
||||
catalog *dnfskill.Table,
|
||||
record dnfrepo.SkillRecord,
|
||||
) ([]darkcombo.Page, error) {
|
||||
job, ok := characterJobByte(character)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dark knight combo character has invalid job %q", character.Job)
|
||||
}
|
||||
if job != darkcombo.Job {
|
||||
return nil, nil
|
||||
}
|
||||
if repos.Settings == nil {
|
||||
return nil, fmt.Errorf("dark knight combo settings repository is unavailable")
|
||||
}
|
||||
pages, found, err := darkcombo.LoadPages(ctx, repos.Settings, character.CharacterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load dark knight combo pages: %w", err)
|
||||
}
|
||||
if found {
|
||||
return pages, nil
|
||||
}
|
||||
s.initialEquipmentMu.Lock()
|
||||
if s.initialEquipmentArchive == nil && strings.TrimSpace(s.options.pvfPath) == "" {
|
||||
s.initialEquipmentMu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
||||
s.initialEquipmentMu.Unlock()
|
||||
if archiveErr != nil {
|
||||
return nil, fmt.Errorf("load dark knight combo PVF: %w", archiveErr)
|
||||
}
|
||||
pages, err = darkcombo.BuildDefaultPages(archive, catalog, record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build dark knight combo pages: %w", err)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("job 9 has no learned PVF combo-set roots")
|
||||
}
|
||||
if err := darkcombo.SavePages(ctx, repos.Settings, character.CharacterID, pages, s.currentDarkKnightComboNow()); err != nil {
|
||||
return nil, fmt.Errorf("persist default dark knight combo pages: %w", err)
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// sendCurrentDarkKnightComboInitialization sends the native class-0/op447
|
||||
// body while a selected Dark Knight is being built. It is deliberately not
|
||||
// part of ordinary op19 reloads or op454 refreshes. The current client does,
|
||||
// however, clear the native combo manager after a final scene rebuild. The
|
||||
// completed-dungeon, completed-return, and typed-town-transition boundaries
|
||||
// therefore restore this exact packet only after the actor skill manager has
|
||||
// been rebuilt.
|
||||
func (s *Service) sendCurrentDarkKnightComboInitialization(
|
||||
session *gameSession,
|
||||
ctx context.Context,
|
||||
character dnfrepo.CharacterRecord,
|
||||
source string,
|
||||
) error {
|
||||
if s == nil || session == nil {
|
||||
return nil
|
||||
}
|
||||
job, ok := characterJobByte(character)
|
||||
if !ok || job != darkcombo.Job {
|
||||
return nil
|
||||
}
|
||||
repos, ok := s.repositoryGroup()
|
||||
if !ok || repos.Skill == nil || repos.Settings == nil {
|
||||
return fmt.Errorf("dark knight combo initialization repositories are unavailable")
|
||||
}
|
||||
record, found, err := repos.Skill.Load(ctx, character.CharacterID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dark knight combo skill record: %w", err)
|
||||
}
|
||||
if !found || len(record.Skills) == 0 {
|
||||
return fmt.Errorf("dark knight combo skill record is unavailable after op19 initialization")
|
||||
}
|
||||
s.initialSkillsMu.Lock()
|
||||
catalog := s.skillCatalog
|
||||
s.initialSkillsMu.Unlock()
|
||||
if catalog == nil {
|
||||
return fmt.Errorf("dark knight combo skill catalog is unavailable")
|
||||
}
|
||||
pages, err := s.loadDarkKnightComboPages(ctx, repos, character, catalog, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := darkcombo.EncodeNotificationBody(pages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode dark knight combo initialization: %w", err)
|
||||
}
|
||||
if err := s.sendGameUpperRawClass(session, currentDarkKnightComboInitializeMsgID, body, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
projectionGeneration := session.darkKnightCombo.recordProjection(
|
||||
session.selectedCharacterID,
|
||||
pages,
|
||||
s.currentDarkKnightComboNow(),
|
||||
)
|
||||
s.logGameEvent(session, "game-dark-knight-combo-initialized",
|
||||
"source", source,
|
||||
"character_id", character.CharacterID,
|
||||
"msg_id", currentDarkKnightComboInitializeMsgID,
|
||||
"classification", 0,
|
||||
"apply_and_echo", 0,
|
||||
"page_count", len(pages),
|
||||
"projection_generation", projectionGeneration,
|
||||
"body_source", "current_exe_sub_1D4B070_class0_op447")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSelectedDarkKnightComboInitialization loads the committed selected actor
|
||||
// at a final scene boundary and restores only its native job-9 combo pages.
|
||||
// It is intentionally safe for every other job: those paths emit no packet.
|
||||
func (s *Service) sendSelectedDarkKnightComboInitialization(session *gameSession, source string) error {
|
||||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||||
return nil
|
||||
}
|
||||
repos, available := s.repositoryGroup()
|
||||
if !available || repos.Character == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
characterID, _, character, found := s.selectedCharacterForEnter(ctx, session)
|
||||
if characterID == 0 || !found {
|
||||
return nil
|
||||
}
|
||||
return s.sendCurrentDarkKnightComboInitialization(session, ctx, character, source)
|
||||
}
|
||||
|
||||
// sendSelectedDarkKnightComboAfterFinalTownSceneOnce restores the job-9
|
||||
// combo manager only after the selected town actor's deferred tail has
|
||||
// committed its final native mode1. The key includes the native scene/actor
|
||||
// generation, because a completed dungeon return rebuilds the same selected
|
||||
// character without changing the connection's character generation.
|
||||
//
|
||||
// A dedicated serial mutex spans the single op447 attempt while the state
|
||||
// mutex remains available to record the successful projection. Concurrent
|
||||
// ready callbacks wait and re-check the exact character/generation key; a
|
||||
// failed write therefore lets the next callback retry only op447 without
|
||||
// replaying the already committed deferred tail.
|
||||
func (s *Service) sendSelectedDarkKnightComboAfterFinalTownSceneOnce(
|
||||
session *gameSession,
|
||||
source string,
|
||||
) error {
|
||||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
session.townMu.Lock()
|
||||
characterID := session.selectedCharacterID
|
||||
generation := session.sceneBootstrapActorGeneration
|
||||
ready := session.initialTownRouteCharacterID == characterID &&
|
||||
session.initialTownRouteStage >= currentInitialTownRoutePlayerStateSent &&
|
||||
session.sceneBootstrapTailSent &&
|
||||
!session.sceneBootstrapTailDeferred
|
||||
session.townMu.Unlock()
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
state := &session.darkKnightCombo
|
||||
state.finalTownSceneMu.Lock()
|
||||
defer state.finalTownSceneMu.Unlock()
|
||||
|
||||
// This callback may have waited behind a preceding attempt. Re-read the
|
||||
// actor key so a scene or character change cannot be acknowledged by the
|
||||
// previous generation's write.
|
||||
session.townMu.Lock()
|
||||
characterID = session.selectedCharacterID
|
||||
generation = session.sceneBootstrapActorGeneration
|
||||
ready = session.initialTownRouteCharacterID == characterID &&
|
||||
session.initialTownRouteStage >= currentInitialTownRoutePlayerStateSent &&
|
||||
session.sceneBootstrapTailSent &&
|
||||
!session.sceneBootstrapTailDeferred
|
||||
session.townMu.Unlock()
|
||||
if !ready || characterID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
if state.finalTownSceneInitialization &&
|
||||
state.finalTownSceneCharacterID == characterID &&
|
||||
state.finalTownSceneGeneration == generation {
|
||||
state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
state.mu.Unlock()
|
||||
|
||||
// A missing repository/record is retryable and must not close the gate. A
|
||||
// resolved non-Dark-Knight is the only packet-silent successful outcome.
|
||||
repos, available := s.repositoryGroup()
|
||||
if !available || repos.Character == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
resolvedID, _, character, found := s.selectedCharacterForEnter(ctx, session)
|
||||
if !found || resolvedID != characterID {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
job, validJob := characterJobByte(character)
|
||||
if !validJob {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if job == darkcombo.Job {
|
||||
err := s.sendCurrentDarkKnightComboInitialization(session, ctx, character, source)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
cancel()
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.finalTownSceneCharacterID = characterID
|
||||
state.finalTownSceneGeneration = generation
|
||||
state.finalTownSceneInitialization = true
|
||||
state.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendCurrentDarkKnightComboPreload sends the native character-select cache
|
||||
// for existing durable combo pages. New/legacy characters without a saved page
|
||||
// are left for the selected-character op447 initializer, where the PVF-backed
|
||||
// lazy seed is safe and complete.
|
||||
func (s *Service) sendCurrentDarkKnightComboPreload(
|
||||
session *gameSession,
|
||||
ctx context.Context,
|
||||
characters []dnfrepo.CharacterRecord,
|
||||
source string,
|
||||
) error {
|
||||
if s == nil || session == nil || len(characters) == 0 {
|
||||
return nil
|
||||
}
|
||||
repos, ok := s.repositoryGroup()
|
||||
if !ok || repos.Settings == nil {
|
||||
return nil
|
||||
}
|
||||
entries := make([]darkcombo.PreloadEntry, 0, len(characters))
|
||||
for _, character := range characters {
|
||||
job, valid := characterJobByte(character)
|
||||
if !valid || job != darkcombo.Job || character.Slot < 0 || character.Slot > 0xff {
|
||||
continue
|
||||
}
|
||||
pages, found, err := darkcombo.LoadPages(ctx, repos.Settings, character.CharacterID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dark knight combo preload %s: %w", character.CharacterID, err)
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, darkcombo.PreloadEntry{Slot: byte(character.Slot), Pages: pages})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
body, err := darkcombo.EncodePreloadNotificationBody(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode dark knight combo preload: %w", err)
|
||||
}
|
||||
if err := s.sendGameUpperRawClass(session, currentDarkKnightComboPreloadMsgID, body, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logGameEvent(session, "game-dark-knight-combo-preloaded",
|
||||
"source", source,
|
||||
"msg_id", currentDarkKnightComboPreloadMsgID,
|
||||
"classification", 0,
|
||||
"character_count", len(entries),
|
||||
"body_source", "current_exe_sub_1D4B430_class0_op809")
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLegacyDarkKnightComboSkillInfoBody(body []byte) []byte {
|
||||
if _, err := darkcombo.ParsePageBlock(body); err == nil {
|
||||
return append([]byte(nil), body...)
|
||||
}
|
||||
// Captured current-client op508 bodies have one trailing transport byte.
|
||||
// Retain the complete page only when the shortened body validates exactly.
|
||||
for _, trailerSize := range []int{1, 4} {
|
||||
if len(body) <= trailerSize {
|
||||
continue
|
||||
}
|
||||
semantic := body[:len(body)-trailerSize]
|
||||
if _, err := darkcombo.ParsePageBlock(semantic); err == nil {
|
||||
return append([]byte(nil), semantic...)
|
||||
}
|
||||
}
|
||||
return append([]byte(nil), body...)
|
||||
}
|
||||
|
||||
func (s *Service) handleCurrentDarkKnightComboSkillInfo(session *gameSession, body []byte) error {
|
||||
opcode := uint16(dnfenum.CmdPacketComboSkillInfo)
|
||||
page, err := darkcombo.ParsePageBlock(body)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-dark-knight-combo-save-rejected", "reason", err, "body_len", len(body))
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "parse_page")
|
||||
}
|
||||
page, err = darkcombo.NormalizePage(page)
|
||||
if err != nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "normalize_page")
|
||||
}
|
||||
character, repos, err := s.currentDarkKnightComboCharacter(session)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-dark-knight-combo-save-rejected", "reason", err, "body_len", len(body))
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "character")
|
||||
}
|
||||
now := s.currentDarkKnightComboNow()
|
||||
autoCommit := session.darkKnightCombo.consume(session.selectedCharacterID, page.Index, now)
|
||||
guard := session.darkKnightCombo.inspectSave(session.selectedCharacterID, page, now, autoCommit)
|
||||
if guard.reject {
|
||||
s.logGameEvent(session, "game-dark-knight-combo-empty-save-rejected",
|
||||
"character_id", character.CharacterID,
|
||||
"page", page.Index,
|
||||
"projection_generation", guard.generation,
|
||||
"projection_age_ms", guard.age.Milliseconds(),
|
||||
"canonical_hash", fmt.Sprintf("%x", guard.hash[:]),
|
||||
"reason", "unmatched_scene_projection_echo")
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "empty_projection_echo")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
cleaned, layoutChanged, err := persistCurrentDarkKnightComboPage(
|
||||
ctx,
|
||||
repos,
|
||||
character.CharacterID,
|
||||
page,
|
||||
autoCommit,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
s.logGameEvent(session, "game-dark-knight-combo-save-rejected", "reason", err, "body_len", len(body))
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "persist_page_and_layout")
|
||||
}
|
||||
if darkKnightComboPageNonEmpty(page) && !guard.canonicalEcho {
|
||||
session.darkKnightCombo.markAcceptedNonEmpty(session.selectedCharacterID, page.Index, guard.generation)
|
||||
}
|
||||
if layoutChanged {
|
||||
s.logGameEvent(session, "game-dark-knight-combo-auto-save-layout-cleaned",
|
||||
"character_id", character.CharacterID, "page", page.Index, "quick_slots_cleaned", cleaned)
|
||||
}
|
||||
s.logGameEvent(session, "game-dark-knight-combo-saved",
|
||||
"character_id", character.CharacterID,
|
||||
"page", page.Index,
|
||||
"root_count", len(page.Roots),
|
||||
"auto_commit", autoCommit,
|
||||
"quick_slots_cleaned", cleaned,
|
||||
"body_len", len(body))
|
||||
return s.sendGameUpperSuccess(session, opcode, nil)
|
||||
}
|
||||
|
||||
// persistCurrentDarkKnightComboPage keeps the op510 -> op508 auto-commit's
|
||||
// independent combo page and ordinary skill layout in one character-scoped
|
||||
// repository transaction. A failed layout write therefore cannot leave a new
|
||||
// durable page paired with the old shortcut ownership (or vice versa).
|
||||
func persistCurrentDarkKnightComboPage(
|
||||
ctx context.Context,
|
||||
repos dnfrepo.Group,
|
||||
characterID string,
|
||||
page darkcombo.Page,
|
||||
autoCommit bool,
|
||||
now time.Time,
|
||||
) (cleaned int, layoutChanged bool, err error) {
|
||||
if repos.CharacterSettlement == nil {
|
||||
return 0, false, fmt.Errorf("dark knight combo settlement transaction is unavailable")
|
||||
}
|
||||
err = repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Settings == nil || tx.Skill == nil {
|
||||
return fmt.Errorf("dark knight combo settlement repositories are unavailable")
|
||||
}
|
||||
pages, found, err := darkcombo.LoadPages(ctx, tx.Settings, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("persisted combo page template is unavailable")
|
||||
}
|
||||
current, found, err := tx.Skill.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || len(current.Skills) == 0 {
|
||||
return fmt.Errorf("dark knight combo skill record is unavailable inside save transaction")
|
||||
}
|
||||
if err := validateCurrentDarkKnightComboPage(page, pages, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := darkcombo.SavePage(ctx, tx.Settings, characterID, page, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if !autoCommit {
|
||||
return nil
|
||||
}
|
||||
updated, moved, err := darkcombo.CleanSavedChildrenQuickSlots(
|
||||
current.Layouts[int(page.Index)],
|
||||
darkcombo.RootSkillIDs(pages),
|
||||
darkcombo.ChildSkillIDs(page),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleaned = moved
|
||||
layoutChanged = moved > 0
|
||||
if !layoutChanged {
|
||||
return nil
|
||||
}
|
||||
if current.Layouts == nil {
|
||||
current.Layouts = make(map[int]dnfrepo.SkillLayout)
|
||||
}
|
||||
current.Layouts[int(page.Index)] = updated
|
||||
current.UpdatedAt = now.UTC()
|
||||
return dnfrepo.SaveSkillFields(ctx, tx.Skill, current, dnfrepo.SkillFieldLayouts)
|
||||
})
|
||||
return cleaned, layoutChanged, err
|
||||
}
|
||||
|
||||
func validateCurrentDarkKnightComboPage(
|
||||
page darkcombo.Page,
|
||||
currentPages []darkcombo.Page,
|
||||
record dnfrepo.SkillRecord,
|
||||
) error {
|
||||
var template *darkcombo.Page
|
||||
allRoots := darkcombo.RootSkillIDs(currentPages)
|
||||
for index := range currentPages {
|
||||
if currentPages[index].Index == page.Index {
|
||||
template = ¤tPages[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if template == nil || len(page.Roots) != len(template.Roots) {
|
||||
return fmt.Errorf("dark knight combo page %d root template mismatch", page.Index)
|
||||
}
|
||||
seenRoots := make(map[uint16]struct{}, len(page.Roots))
|
||||
for index, root := range page.Roots {
|
||||
if root.SkillID == 0 || root.SkillID != template.Roots[index].SkillID {
|
||||
return fmt.Errorf("dark knight combo page %d root %d mismatch", page.Index, index)
|
||||
}
|
||||
if _, duplicate := seenRoots[root.SkillID]; duplicate {
|
||||
return fmt.Errorf("dark knight combo page %d has duplicate root %d", page.Index, root.SkillID)
|
||||
}
|
||||
seenRoots[root.SkillID] = struct{}{}
|
||||
if len(root.Children) > darkcombo.MaximumStageCount {
|
||||
return fmt.Errorf("dark knight combo root %d has %d stages", root.SkillID, len(root.Children))
|
||||
}
|
||||
}
|
||||
state, found := dnfrepo.SkillTree(record, int(page.Index))
|
||||
if !found {
|
||||
return fmt.Errorf("dark knight combo skill tree %d is unavailable", page.Index)
|
||||
}
|
||||
for _, root := range page.Roots {
|
||||
for _, child := range root.Children {
|
||||
if _, isRoot := allRoots[child]; isRoot {
|
||||
return fmt.Errorf("dark knight combo child %d is a combo root", child)
|
||||
}
|
||||
learned, exists := state.Skills[int64(child)]
|
||||
if !exists || !learned.Enabled || learned.Level <= 0 {
|
||||
return fmt.Errorf("dark knight combo child %d is not learned on page %d", child, page.Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleCurrentDarkKnightComboQuickSlotReset(session *gameSession, body []byte) error {
|
||||
opcode := uint16(dnfenum.CmdPacketComboSkillExtensionQuickSlotReset)
|
||||
if len(body) != 2 || body[0] >= darkcombo.MaximumPageCount || (body[1] != 0 && body[1] != 2) {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "invalid_request")
|
||||
}
|
||||
pageIndex, mode := body[0], body[1]
|
||||
character, repos, err := s.currentDarkKnightComboCharacter(session)
|
||||
if err != nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "character")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
s.initialSkillsMu.Lock()
|
||||
catalog := s.skillCatalog
|
||||
s.initialSkillsMu.Unlock()
|
||||
if catalog == nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "catalog")
|
||||
}
|
||||
record, found, err := repos.Skill.Load(ctx, character.CharacterID)
|
||||
if err != nil || !found {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "skill_record")
|
||||
}
|
||||
pages, err := s.loadDarkKnightComboPages(ctx, repos, character, catalog, record)
|
||||
if err != nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "load_pages")
|
||||
}
|
||||
rootIDs := darkcombo.RootSkillIDs(pages)
|
||||
var savedChildren map[uint16]struct{}
|
||||
for _, candidate := range pages {
|
||||
if candidate.Index == pageIndex {
|
||||
savedChildren = darkcombo.ChildSkillIDs(candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
if savedChildren == nil {
|
||||
savedChildren = make(map[uint16]struct{})
|
||||
}
|
||||
now := s.currentDarkKnightComboNow()
|
||||
owner, ownerErr := currentSceneSkillOwner(repos)
|
||||
if ownerErr != nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "layout_owner")
|
||||
}
|
||||
cleaned := 0
|
||||
_, _, changed, err := owner.EnsureLayout(ctx, dnfskill.EnsureLayoutCommand{
|
||||
CharacterID: character.CharacterID,
|
||||
TreeIndex: int(pageIndex),
|
||||
UpdatedAt: now,
|
||||
Build: func(current dnfrepo.SkillRecord) (dnfrepo.SkillLayout, error) {
|
||||
state, exists := dnfrepo.SkillTree(current, int(pageIndex))
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("dark knight combo skill tree %d is missing", pageIndex)
|
||||
}
|
||||
updated, moved, cleanErr := darkcombo.CleanAutoComboQuickSlots(
|
||||
catalog,
|
||||
state.Skills,
|
||||
current.Layouts[int(pageIndex)],
|
||||
rootIDs,
|
||||
savedChildren,
|
||||
)
|
||||
cleaned = moved
|
||||
return updated, cleanErr
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return s.sendCurrentDarkKnightComboFailureAndRestore(session, opcode, "clean_auto_layout")
|
||||
}
|
||||
if changed {
|
||||
// This is the one 86JP-prescribed ordinary skill refresh: it occurs
|
||||
// before the reset ACK because shortcuts were changed by the server.
|
||||
if err := s.sendSelectedActorCurrentSceneSkillInfoOnly(session, "dark_knight_auto_combo_pre_ack_skill_refresh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
session.darkKnightCombo.arm(session.selectedCharacterID, pageIndex, now.Add(currentDarkKnightComboAutoWindow))
|
||||
s.logGameEvent(session, "game-dark-knight-combo-auto-reset-ready",
|
||||
"character_id", character.CharacterID,
|
||||
"page", pageIndex,
|
||||
"mode", mode,
|
||||
"quick_slots_cleaned", cleaned,
|
||||
"skill_info_refreshed", changed,
|
||||
"pending_window_ms", currentDarkKnightComboAutoWindow.Milliseconds())
|
||||
return s.sendGameUpperSuccess(session, opcode, nil)
|
||||
}
|
||||
|
||||
func (s *Service) sendCurrentDarkKnightComboFailureAndRestore(
|
||||
session *gameSession,
|
||||
opcode uint16,
|
||||
source string,
|
||||
) error {
|
||||
if err := s.sendGameUpperFailure(session, opcode, currentDarkKnightComboFailureCode); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendSelectedDarkKnightComboInitialization(session, "failure_restore_"+source)
|
||||
}
|
||||
|
||||
func (s *Service) currentDarkKnightComboCharacter(session *gameSession) (dnfrepo.CharacterRecord, dnfrepo.Group, error) {
|
||||
if session == nil || session.selectedCharacterID == 0 {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, fmt.Errorf("selected character is unavailable")
|
||||
}
|
||||
repos, ok := s.repositoryGroup()
|
||||
if !ok || repos.Character == nil || repos.Skill == nil || repos.Settings == nil {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, fmt.Errorf("dark knight combo repositories are unavailable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
characterID := fmt.Sprintf("%d", session.selectedCharacterID)
|
||||
character, found, err := repos.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, err
|
||||
}
|
||||
if !found {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, fmt.Errorf("character %s was not found", characterID)
|
||||
}
|
||||
if accountID := strings.TrimSpace(s.accountIDForSession(session)); accountID != "" && accountID != strings.TrimSpace(character.AccountID) {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, fmt.Errorf("character %s owner mismatch", characterID)
|
||||
}
|
||||
job, ok := characterJobByte(character)
|
||||
if !ok || job != darkcombo.Job {
|
||||
return dnfrepo.CharacterRecord{}, dnfrepo.Group{}, fmt.Errorf("character %s is not dark knight", characterID)
|
||||
}
|
||||
return character, repos, nil
|
||||
}
|
||||
Reference in new issue
Block a user