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