服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1242 lines
53 KiB
Go
1242 lines
53 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
darkcombo "longheng.io/server/internal/modules/dnf/darkknightcombo"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
|
||
"longheng.io/server/internal/modules/dnf/progression"
|
||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfskill "longheng.io/server/internal/modules/dnf/skill"
|
||
)
|
||
|
||
var errCurrentFinishQuestAckShape = errors.New("current EXE finish-quest ACK shape is invalid")
|
||
|
||
// currentFinishQuestReplayKey scopes one terminal receipt to the selected
|
||
// character in one TCP session. Character selection can reuse its connection,
|
||
// so a bare quest ID would incorrectly suppress another character's receipt.
|
||
type currentFinishQuestReplayKey struct {
|
||
characterID uint16
|
||
questID uint16
|
||
}
|
||
|
||
func newCurrentFinishQuestReplayKey(characterID uint16, request dnfquest.FinishQuestRequest) currentFinishQuestReplayKey {
|
||
return currentFinishQuestReplayKey{characterID: characterID, questID: request.QuestID}
|
||
}
|
||
|
||
// currentFinishQuestReplay helpers keep the durable replay receipt scoped to
|
||
// reconnect recovery. A live same-session duplicate finish request (double
|
||
// click or client retry) must not receive the receipt a second time: the
|
||
// current EXE finish handler (sub_1D32120) drops the quest UI entry after the
|
||
// first receipt and NULL-derefs on the duplicate, killing the client
|
||
// (2026-07-25 quest 3157 crash at 0x1D3340F).
|
||
func (session *gameSession) currentFinishQuestAnswered(key currentFinishQuestReplayKey) bool {
|
||
if session == nil {
|
||
return false
|
||
}
|
||
session.questReplay.finishMu.Lock()
|
||
defer session.questReplay.finishMu.Unlock()
|
||
_, ok := session.questReplay.finishAnswered[key]
|
||
return ok
|
||
}
|
||
|
||
func (session *gameSession) markCurrentFinishQuestAnswered(key currentFinishQuestReplayKey) {
|
||
if session == nil {
|
||
return
|
||
}
|
||
session.questReplay.finishMu.Lock()
|
||
defer session.questReplay.finishMu.Unlock()
|
||
if session.questReplay.finishAnswered == nil {
|
||
session.questReplay.finishAnswered = make(map[currentFinishQuestReplayKey]struct{}, 1)
|
||
}
|
||
session.questReplay.finishAnswered[key] = struct{}{}
|
||
}
|
||
|
||
func (s *Service) handleCurrentFinishQuest(session *gameSession, body []byte) error {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return nil
|
||
}
|
||
request, err := dnfquest.DecodeFinishQuestRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"body_len", len(body),
|
||
"reason", "current_exe_op34_requires_exact_plain_10_byte_body",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
// The current live ten-byte request ends in FF FF. The field's domain
|
||
// meaning remains unproved, so validate only the observed wire marker.
|
||
if request.QuestID == 0 || request.Multiplier != 1 || request.Reserved != dnfquest.CurrentFinishQuestObservedTailMarker {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"multiplier", request.Multiplier,
|
||
"reserved", request.Reserved,
|
||
"reason", "current_exe_op34_semantic_fields_invalid")
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketFinishQuest), 22)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.CharacterSettlement == nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "character_settlement_transaction_unavailable")
|
||
return nil
|
||
}
|
||
catalog, err := s.loadQuestCatalog(ctx)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "quest_catalog_unavailable",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
if handled, linkedErr := s.handleCurrentLinkedObjectiveFinish(ctx, session, request, repositories, catalog); handled {
|
||
if linkedErr != nil {
|
||
s.logGameEvent(session, "game-upper-linked-objective-finish-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "atomic_linked_progress_failed",
|
||
"error", linkedErr)
|
||
}
|
||
return linkedErr
|
||
}
|
||
tables, itemCatalog, err := s.currentQuestFinishResources(ctx)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "progression_or_item_pvf_unavailable",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
var professionResources []currentQuestProfessionResources
|
||
// The client submits the canonical quest ID. Some PVF story nodes use a
|
||
// hidden profession/awakening companion as the actual reward source, so
|
||
// checking only catalog.Find(request.QuestID) misses the required tables and
|
||
// makes the atomic owner reject an otherwise valid finish. Resolve the same
|
||
// final reward source used by the owner before deciding whether to preload.
|
||
finishCharacterID, _, finishCharacter, finishCharacterFound := s.selectedCharacterForEnter(ctx, session)
|
||
finishQuestRecord := dnfrepo.QuestRecord{}
|
||
if finishCharacterFound && repositories.Quest != nil {
|
||
characterID := finishCharacter.CharacterID
|
||
if characterID == "" {
|
||
characterID = strconv.Itoa(int(finishCharacterID))
|
||
}
|
||
if loaded, found, loadErr := repositories.Quest.Load(ctx, characterID); loadErr == nil && found {
|
||
finishQuestRecord = loaded
|
||
}
|
||
}
|
||
finishJob, finishJobValid := characterJobByte(finishCharacter)
|
||
finishEligibility := dnfquest.CharacterEligibility{
|
||
Level: finishCharacter.Level,
|
||
Job: int(finishJob),
|
||
GrowType: int(numericCharacterStatValue(finishCharacter, "grow_type")),
|
||
}
|
||
if finishCharacterFound && finishJobValid && catalog.FinishRewardNeedsProfessionResources(
|
||
finishQuestRecord,
|
||
finishEligibility,
|
||
int64(request.QuestID),
|
||
request.RewardSelectIndex,
|
||
request.HasRewardSelect,
|
||
) {
|
||
profiles, skillCatalog, err := s.currentProfessionResources(ctx)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "profession_pvf_resources_unavailable",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
professionResources = append(professionResources, currentQuestProfessionResources{Profiles: profiles, SkillCatalog: skillCatalog})
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
mainSlotCount := uint16(0)
|
||
if repositories.Settings != nil {
|
||
containerState := s.loadCurrentItemListContainerState(ctx, repositories.Settings, characterID, dnfrepo.MainInventoryListType)
|
||
mainSlotCount = containerState.MainSlotCount
|
||
}
|
||
return s.handleCurrentFinishQuestWithResources(
|
||
session,
|
||
request,
|
||
repositories,
|
||
catalog,
|
||
tables,
|
||
currentQuestFinishItemAllocator(itemCatalog, mainSlotCount),
|
||
professionResources...,
|
||
)
|
||
}
|
||
|
||
type currentQuestProfessionResources struct {
|
||
Profiles *dnfprofession.Profiles
|
||
SkillCatalog *dnfskill.Table
|
||
}
|
||
|
||
func (s *Service) handleCurrentFinishQuestWithResources(
|
||
session *gameSession,
|
||
request dnfquest.FinishQuestRequest,
|
||
repositories dnfrepo.Group,
|
||
catalog *dnfquest.Catalog,
|
||
tables *progression.Tables,
|
||
allocator dnfquest.FinishItemAllocator,
|
||
professionResources ...currentQuestProfessionResources,
|
||
) error {
|
||
owner, err := dnfquest.NewOwner(repositories)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
var professionProfiles *dnfprofession.Profiles
|
||
var skillCatalog *dnfskill.Table
|
||
if len(professionResources) > 0 {
|
||
professionProfiles = professionResources[0].Profiles
|
||
skillCatalog = professionResources[0].SkillCatalog
|
||
}
|
||
accountID := s.accountIDForSession(session)
|
||
var hellPartyUnlocks []dnfquest.HellPartyUnlock
|
||
if catalog.HasHellPartyUnlock(int64(request.QuestID)) {
|
||
hellPartyUnlocks, err = s.currentQuestHellPartyUnlocks(request.QuestID)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reason", "hell_party_pvf_area_mapping_unavailable",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
}
|
||
var experienceBonusPercent int64
|
||
if growthEffect, active := s.currentGrowthContractEffect(ctx, accountID); active {
|
||
experienceBonusPercent = growthEffect.BonusExperiencePercent
|
||
}
|
||
// 幸运角色(回归活动的角色表,chn_activatecharacter.etc)的任务经验加成。
|
||
// 它按"当前角色"判定,所以必须读一次角色行;读不到就按无加成处理,
|
||
// 不让一个活动加成把任务交付整个卡住。
|
||
if ch, found, err := repositories.Character.Load(ctx, characterID); err != nil {
|
||
return err
|
||
} else if found {
|
||
if lucky := s.currentLuckyCharacterEffect(session, ch); lucky != nil {
|
||
experienceBonusPercent += lucky.Quest
|
||
}
|
||
}
|
||
// 冒险团运行时配置(商店点数换算 / 成长胶囊槽):满级任务经验的溢出部分
|
||
// 用它换算成骁勇结晶,与角色经验同事务提交。
|
||
adventureRuntime := s.adventureGroupTable.Runtime()
|
||
result, err := owner.ApplyFinishSettlement(ctx, catalog, dnfquest.FinishCommitInput{
|
||
AccountID: accountID,
|
||
CharacterID: characterID,
|
||
QuestID: int64(request.QuestID),
|
||
RewardSelectIndex: request.RewardSelectIndex,
|
||
HasRewardSelect: request.HasRewardSelect,
|
||
Multiplier: request.Multiplier,
|
||
CommittedAt: time.Now().UTC(),
|
||
Progression: tables,
|
||
ExperienceBonusPercent: experienceBonusPercent,
|
||
AllocateItem: allocator,
|
||
ProfessionProfiles: professionProfiles,
|
||
SkillCatalog: skillCatalog,
|
||
HellPartyUnlocks: hellPartyUnlocks,
|
||
// 满级任务经验的溢出部分要在同一个事务里进骁勇结晶/成长胶囊槽。
|
||
AdventureRuntime: &adventureRuntime,
|
||
})
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"reward_select", request.RewardSelectIndex,
|
||
"has_reward_select", request.HasRewardSelect,
|
||
"reason", "pvf_db_atomic_finish_rejected",
|
||
"error", err)
|
||
if currentFinishQuestBusinessFailure(err) {
|
||
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketFinishQuest), 22)
|
||
}
|
||
return nil
|
||
}
|
||
// 支援兵跟随主角转职/觉醒:主角这笔任务结算提交之后再镜像给支援兵。
|
||
// 每次镜像都带哈希回执(companion_progression_receipt_*),重试安全;
|
||
// 镜像失败只记 game-dungeon-companion-progression-failed,不影响主角结算。
|
||
s.syncCurrentDungeonCompanionProgression(
|
||
ctx,
|
||
session,
|
||
"quest:"+result.CompletionKey,
|
||
tables,
|
||
result.ExperienceDelta,
|
||
&result.PostCommitQuest,
|
||
)
|
||
payload, err := buildCurrentFinishQuestSuccessBody(result)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-blocked",
|
||
"quest_id", request.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"reason", "committed_result_ack_shape_invalid",
|
||
"error", err)
|
||
return nil
|
||
}
|
||
replayKey := newCurrentFinishQuestReplayKey(session.selectedCharacterID, request)
|
||
if result.Replayed && session.currentFinishQuestAnswered(replayKey) {
|
||
// Same-session duplicate of an already-answered finish: the first
|
||
// receipt completed the client's quest UI flow; resending it NULL-derefs
|
||
// the client handler. Reconnect recovery is unaffected because a fresh
|
||
// session has an empty answered set and still receives the replay.
|
||
s.logGameEvent(session, "game-upper-finish-quest-replay-suppressed",
|
||
"quest_id", request.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"reason", "same_session_duplicate_receipt_would_crash_client_handler")
|
||
return nil
|
||
}
|
||
s.logGameEvent(session, "game-upper-finish-quest-success-send",
|
||
"quest_id", result.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"reward_source", result.Source,
|
||
"base_experience_delta", result.BaseExperienceDelta,
|
||
"growth_contract_bonus", result.ExperienceBonusDelta,
|
||
"main_continuity_bonus", result.ContinuityExperienceDelta,
|
||
"experience_delta", result.ExperienceDelta,
|
||
"level_before", result.PreviousLevel,
|
||
"level_after", result.NewLevel,
|
||
"sp_delta", result.SPDelta,
|
||
"item_count", len(result.Items),
|
||
"consumed_count", len(result.ConsumedItems),
|
||
"currency_count", len(result.Currency),
|
||
// 券直接入账号钱包的数量(0 = 本次奖励里没有券)。
|
||
"voucher_token_cera", result.VoucherCredits.TokenCera,
|
||
"voucher_happy_token_cera", result.VoucherCredits.HappyTokenCera,
|
||
"has_profession", result.HasProfession,
|
||
"profession_chain_type", result.Profession.ChainType,
|
||
"profession_grow_number", result.Profession.GrowNumber,
|
||
"profession_grow_before", result.Profession.PreviousGrowType,
|
||
"profession_grow_after", result.Profession.NewGrowType,
|
||
"profession_grant_count", len(result.ProfessionGrants),
|
||
"has_expert_job", result.HasExpertJob,
|
||
"expert_job_type", result.ExpertJobType,
|
||
"replayed", result.Replayed,
|
||
"msg_id", uint16(dnfenum.CmdPacketFinishQuest),
|
||
"classification", dnfproto.DefaultChannelClassification,
|
||
"plain_body_len", len(payload)+1,
|
||
"body_source", "current_exe_sub_1D32120_and_atomic_pvf_finish_receipt")
|
||
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketFinishQuest), payload); err != nil {
|
||
return err
|
||
}
|
||
// The client can safely receive no more than one terminal receipt per
|
||
// selected character/session, including the first durable replay after a
|
||
// reconnect. Mark only after the ACK write succeeds so a failed transport
|
||
// remains retryable.
|
||
session.markCurrentFinishQuestAnswered(replayKey)
|
||
// 任务奖励里的代币券 / 欢乐代币券已经直接进账号钱包(没有占背包格),
|
||
// 所以必须把新余额推给客户端,否则玩家看不到到账。
|
||
// 用 result.VoucherWallet(事务里算出来的权威值)而不是重新读一次;
|
||
// 纯展示:失败只记日志,不让一次已经提交的任务结算变成失败。
|
||
if result.VoucherWallet != nil {
|
||
if err := s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID,
|
||
buildCurrentCeraShopWalletBody(result.VoucherWallet.Cera, result.VoucherWallet.TokenCera, result.VoucherWallet.HappyTokenCera),
|
||
currentCeraShopClass0); err != nil {
|
||
s.logGameEvent(session, "game-cera-shop-wallet-send-failed", "source", "quest_finish_voucher_credit", "err", err)
|
||
}
|
||
}
|
||
if result.HasExpertJob {
|
||
// Current NoPack sub_1D32120 consumes chain type 20 as part of the
|
||
// finish ACK, but the auxiliary-profession domain state has its own
|
||
// class-0/op205 reader (sub_1D89C20). Publish that typed state
|
||
// immediately after the ACK and before the generic character/quest
|
||
// refreshes. 86JP's legacy op2 subtype0 and current op754 are not valid
|
||
// local-actor carriers for this EXE; its compatibility unit mirrors the
|
||
// validated op205 type into the existing current actor without a panel.
|
||
//
|
||
// This is the ONE op205 send that is a genuine acquisition: completing
|
||
// an expert-job quest is exactly when the client's native qualification
|
||
// window is wanted. Every other op205 send restates state the client
|
||
// already holds and is marked silent, because the native reader cannot
|
||
// tell the two apart and raises the same window for both.
|
||
if err := s.sendCurrentExpertJobInfoForCharacterAnnouncing(session, result.PostCommitCharacter, true); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// 章节过场必须在场景重建(op24)**之前**下发。
|
||
//
|
||
// 当前客户端是在场景 finalizer 里读 op1378、据此决定播哪一段;排在 op24
|
||
// 之后的下发会被直接忽略。实测证据:完成任务当次不播,而小退重进(角色选择
|
||
// 阶段下发,天然早于 finalizer)就能播。
|
||
//
|
||
// sendCurrentFinishQuestPostCommitSnapshots 内部末尾会调
|
||
// sendCurrentQuestGatedTownSceneRefresh(op22→op23→op356→同区域 op24),
|
||
// 所以过场下发必须排在它前面。
|
||
if err := s.sendCurrentFinishQuestStoryDigestBeforeSceneRefresh(session, result); err != nil {
|
||
return err
|
||
}
|
||
if result.HasProfession {
|
||
err = s.sendCurrentProfessionFinishPostCommitSnapshots(session, result)
|
||
} else {
|
||
err = s.sendCurrentFinishQuestPostCommitSnapshots(session, result)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 注意:等级变化本身**不**制造段差,也不会触发章节动画。
|
||
//
|
||
// 客户端内部 last_level 只在收到 op1378 时更新;升级后如果不同步,客户端
|
||
// 会一直停在旧段,下一次场景 finalizer 就判定"段差"并自动补播剧情摘要 +
|
||
// 章节动画。因此非开区任务的升级会在 op37 之后补发一次当前等级(见
|
||
// sendCurrentFinishQuestPostCommitSnapshots);唯一保留段差的地方仍然是
|
||
// 「本次任务开启了带过场的城镇区域」。
|
||
if result.HasProfession || result.NewLevel != result.PreviousLevel {
|
||
s.notifyCurrentUnitedFriendPresence(
|
||
result.PostCommitCharacter.Name,
|
||
s.currentUnitedFriendPresence(selectedCharacterID(session)),
|
||
"quest_progression_friend_absolute_refresh",
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentFinishQuestStoryDigestBeforeSceneRefresh 在「本次完成的任务开启了带过场的
|
||
// 城镇区域」时下发 op1378,让客户端播放该区域的章节过场影片。
|
||
//
|
||
// 这是章节过场动画的**唯一**触发入口。原先这里按 result.NewLevel > PreviousLevel
|
||
// 下发,等价于「等级 = 章节进度」—— 升一级就播一次,跟任务做没做无关,已移除。
|
||
//
|
||
// 机制(PVF 实测):
|
||
//
|
||
// 城镇 .twn 的 [permission] 声明 [need level] + [need quest];
|
||
// 两者都满足 → 该区域开启。这是「章节过场」的触发条件。
|
||
// etc/townmovielist.etc 给出该 (town, area) 要播的影片;
|
||
// etc/storydigest/digest.etc 给出该影片所属段的等级下界。
|
||
// 客户端比较「由 last_level 选出的段」与「由当前等级选出的段」,不同才播。
|
||
//
|
||
// 因此这里把 last_level 下发成**该影片所属段的等级下界**,客户端即在该段
|
||
// 结束处播放对应影片。
|
||
func (s *Service) sendCurrentFinishQuestStoryDigestBeforeSceneRefresh(
|
||
session *gameSession,
|
||
result dnfquest.FinishCommitResult,
|
||
) error {
|
||
if result.Replayed {
|
||
return nil
|
||
}
|
||
if result.NewLevel < 0 || uint64(result.NewLevel) > uint64(math.MaxUint32) {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
if result.QuestID <= 0 {
|
||
return nil
|
||
}
|
||
|
||
// 本次完成的任务是否开启了带过场的城镇区域?
|
||
characterLevel := int64(result.PostCommitCharacter.Level)
|
||
openedTownID, openedArea, movie, opened := s.townMovieAreaOpenedByQuest(
|
||
0, result.QuestID, characterLevel, true)
|
||
if !opened {
|
||
s.logGameEvent(session, "game-upper-finish-quest-story-digest-suppressed",
|
||
"quest_id", result.QuestID,
|
||
"character_level", characterLevel,
|
||
"previous_level", result.PreviousLevel,
|
||
"new_level", result.NewLevel,
|
||
"reason", "quest_opens_no_town_area_with_cutscene")
|
||
// 本次没有开区过场:如果等级变了,就把持久化的 last_level 拉平到新等级,
|
||
// 避免客户端下次比较时看到"段差"而在登录/小退重进时补播一段过场。
|
||
// 这是之前那次修复里 game-current-story-digest-level-synced 那一步。
|
||
if result.NewLevel != result.PreviousLevel {
|
||
if syncErr := s.syncCurrentStoryDigestLevelForQuest(
|
||
session, result, "finish_quest_after_cutscene_digest"); syncErr != nil {
|
||
s.logGameEvent(session, "game-current-story-digest-level-sync-failed",
|
||
"quest_id", result.QuestID,
|
||
"character_level", result.NewLevel,
|
||
"error", syncErr)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 开区已确认:直接写客户端触发文件,让补丁**当场**调用客户端播放器播这段影片。
|
||
// 这条路径不依赖 op1378 的播放判定(那条只在角色选择时生效)。
|
||
// 延迟 2 秒再写:开区这一刻客户端正在重建场景(op22→op23→op356→op24),
|
||
// 立刻播放会被场景重建拆掉窗口。等重建稳定后再触发,影片才能正常显示。
|
||
if sequence, sequenceOK := townMovieCinematicSequence(movie.FilePath); sequenceOK {
|
||
s.armCutsceneAfterSceneReady(session, sequence)
|
||
if triggerErr := error(nil); triggerErr != nil {
|
||
s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-trigger-failed",
|
||
"quest_id", result.QuestID,
|
||
"movie", movie.FilePath,
|
||
"sequence", sequence,
|
||
"error", triggerErr)
|
||
} else {
|
||
s.logGameEvent(session, "game-upper-finish-quest-cutscene-file-triggered",
|
||
"quest_id", result.QuestID,
|
||
"opened_town_id", openedTownID,
|
||
"opened_area_id", openedArea.ID,
|
||
"movie", movie.FilePath,
|
||
"sequence", sequence)
|
||
}
|
||
}
|
||
|
||
digestLevel, segmentIndex, ok := s.storyDigestLevelForTownMovie(movie)
|
||
if !ok {
|
||
s.logGameEvent(session, "game-upper-finish-quest-story-digest-suppressed",
|
||
"quest_id", result.QuestID,
|
||
"opened_town_id", openedTownID,
|
||
"opened_area_id", openedArea.ID,
|
||
"movie", movie.FilePath,
|
||
"reason", "town_area_movie_is_not_mapped_to_a_story_digest_segment")
|
||
return nil
|
||
}
|
||
if digestLevel < 0 || uint64(digestLevel) > uint64(math.MaxUint32) {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
lastLevel := currentStoryDigestLastLevel(result.PostCommitCharacter, true)
|
||
s.logGameEvent(session, "game-upper-finish-quest-town-area-cutscene-digest",
|
||
"quest_id", result.QuestID,
|
||
"opened_town_id", openedTownID,
|
||
"opened_area_id", openedArea.ID,
|
||
"opened_area_map", openedArea.MapPath,
|
||
"need_level", openedArea.MinLevel,
|
||
"need_quests", openedArea.NeedQuests,
|
||
"character_level", characterLevel,
|
||
"movie", movie.FilePath,
|
||
"movie_title", movie.Title,
|
||
"story_digest_segment", segmentIndex,
|
||
"story_digest_level", digestLevel,
|
||
"previous_last_level", lastLevel,
|
||
"trigger", "town_area_opening_quest")
|
||
// 这里是**唯一**允许制造"两个小节不同"的地方(见 story_digest.go 的设计约定):
|
||
// 常规下发恒等于角色当前等级 → 登录/小退/重登都不会补播;只有开区任务完成时
|
||
// 显式下发 digestLevel(该影片段的等级下界 - 1),让客户端在这次场景构建里
|
||
// 播一次该区域的章节动画。
|
||
//
|
||
// 只播一次:区域一旦开启(NeedQuests 已满足、等级已达标),
|
||
// townMovieAreaOpenedByQuest 不会再对同一区域返回 true,因此不会重复触发;
|
||
// 而常规下发恒等于当前等级,也不会再补播。
|
||
return s.sendCurrentStoryDigestLastLevelValue(
|
||
session,
|
||
uint32(digestLevel),
|
||
"finish_quest_town_area_opening_cutscene_before_scene_refresh",
|
||
)
|
||
}
|
||
|
||
func (s *Service) currentQuestHellPartyUnlocks(questID uint16) ([]dnfquest.HellPartyUnlock, error) {
|
||
if s == nil || questID == 0 {
|
||
return nil, nil
|
||
}
|
||
table, _, err := s.dungeonWorldMap()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
areas := table.HellPartyAreasForQuest(int64(questID))
|
||
if len(areas) == 0 {
|
||
return nil, nil
|
||
}
|
||
unlock := make([]dnfquest.HellPartyUnlock, 0, len(areas))
|
||
for _, area := range areas {
|
||
if area.ID <= 0 {
|
||
return nil, errDungeonWorldMapUnavailable
|
||
}
|
||
unlock = append(unlock, dnfquest.HellPartyUnlock{AreaID: area.ID, QuestID: int64(questID)})
|
||
}
|
||
return unlock, nil
|
||
}
|
||
|
||
func currentQuestRewardIsProfession(value string) bool {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if len(value) >= 2 && value[0] == '[' && value[len(value)-1] == ']' {
|
||
value = strings.TrimSpace(value[1 : len(value)-1])
|
||
}
|
||
return value == "grow type" || value == "awakening type"
|
||
}
|
||
|
||
func validateCurrentProfessionFinishResult(session *gameSession, result dnfquest.FinishCommitResult) error {
|
||
if session == nil {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
if !result.AtomicCommitted || !result.HasProfession || result.CharacterID != characterID ||
|
||
result.PostCommitCharacter.CharacterID != characterID || result.PostCommitSkill.CharacterID != characterID ||
|
||
result.PostCommitCharacter.Stats["grow_type"] != int64(result.Profession.NewGrowType) {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sendCurrentProfessionFinishPostCommitSnapshots applies only repository/PVF
|
||
// state returned by the committed quest owner. Current EXE sub_1D32120 owns
|
||
// the profession transition: its op34 inline two-tree grant lists update the
|
||
// local grow type, profession dialog and native skill manager together. An
|
||
// op19 before op34 closes that dialog; a full mode0/mode1 rebuild or another
|
||
// op19 after op34 replaces the newly committed skill manager and leaves the
|
||
// skill window unable to issue buy-skill requests. The current weapon-change
|
||
// trace proves that one existing-actor mode0 row with its initialization event
|
||
// suppressed is safe: it updates the bound actor's grow type without recreating
|
||
// the skill manager. Keep op34 first, then use only that in-place actor refresh
|
||
// before the absolute point ledger and quest views.
|
||
func (s *Service) sendCurrentProfessionFinishPostCommitSnapshots(session *gameSession, result dnfquest.FinishCommitResult) error {
|
||
if err := validateCurrentProfessionFinishResult(session, result); err != nil {
|
||
return err
|
||
}
|
||
if currentTownSelfActorAppearanceReady(session) {
|
||
if err := s.sendSelectedActorMode0AppearanceRefresh(
|
||
session,
|
||
"finish_profession",
|
||
"current_exe_op34_inline_profession_transition_then_existing_actor_mode0_event_suppressed_then_op37_quests",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
// The committed grow type is still durable and the next normal town actor
|
||
// bootstrap will carry it. Do not emit mode0 across a town transition.
|
||
s.logGameEvent(session, "game-upper-finish-profession-actor-refresh-deferred",
|
||
"character_id", result.CharacterID,
|
||
"grow_type", result.Profession.NewGrowType,
|
||
"reason", "selected_town_actor_generation_not_ready")
|
||
}
|
||
characterBody := buildCurrentFinishLoadingCharacterStateBody(result.PostCommitCharacter, result.PostCommitSkill.Points)
|
||
if len(characterBody) != currentFinishLoadingCharacterStateBodySize {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentDungeonCharacterStateMsgID, characterBody, 0); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentActiveQuestSnapshotFromCommittedQuest(
|
||
session,
|
||
result.PostCommitQuest,
|
||
"current_exe_op34_after_client_owned_profession_skill_manager_refresh",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentClearQuestListFromCommittedQuest(
|
||
session,
|
||
result.PostCommitQuest,
|
||
"current_exe_op34_after_profession_active_snapshot_completed_quest_scene_refresh",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentAcceptableQuestListOnlyForSession(session, "current_exe_op34_after_profession_active_snapshot"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendCurrentQuestGatedTownSceneRefresh(
|
||
session,
|
||
result,
|
||
"current_exe_op34_after_profession_task_snapshots",
|
||
)
|
||
}
|
||
|
||
// sendCurrentFinishQuestPostCommitSnapshots publishes only the immutable state
|
||
// returned by the committed settlement owner. Ordinary finishes must not push
|
||
// mode1/mode3 personal-information snapshots: current mode3 opens the personal
|
||
// information panel, and both bodies rebuild the entire actor/equipment view.
|
||
// The finish ACK owns ordinary EXP/item changes. A rare slot-expansion reward
|
||
// still needs one committed mode1 projection because that is the proved online
|
||
// carrier for ex_equip_slot_stat; mode3 remains suppressed.
|
||
//
|
||
// The order is intentional: changed inventory rows first, then only genuinely
|
||
// changed character/skill state, and finally the quest views. The op34 finish
|
||
// ACK carries the EXP delta but does not repaint the HUD bar for a same-level
|
||
// reward, so every nonzero EXP reward also publishes the committed absolute
|
||
// op37 character state. Skill information remains limited to changes that can
|
||
// affect the skill tree. A committed level change replaces the ordinary delta
|
||
// pair with the current client's complete task-manual snapshot so newly
|
||
// level-gated quests appear at once.
|
||
func (s *Service) sendCurrentFinishQuestPostCommitSnapshots(session *gameSession, result dnfquest.FinishCommitResult) error {
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
if !result.AtomicCommitted || result.CharacterID != characterID ||
|
||
result.PostCommitCharacter.CharacterID != characterID ||
|
||
result.PostCommitSkill.CharacterID != characterID {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
if err := s.sendCurrentFinishQuestRewardItemUpdate(session, result); err != nil {
|
||
return err
|
||
}
|
||
if result.HasSlotExpansion {
|
||
if err := s.sendCurrentFinishQuestSlotExpansionMode1Snapshot(session, result); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-upper-finish-quest-slot-expansion",
|
||
"quest_id", result.QuestID,
|
||
"slot_expansion_index", result.SlotExpansionIndex,
|
||
"slot_expansion_bit", result.SlotExpansionBit,
|
||
"ex_equip_slot_stat", result.PostCommitCharacter.Stats["ex_equip_slot_stat"])
|
||
}
|
||
characterStateChanged := result.ExperienceDelta != 0 ||
|
||
result.NewLevel != result.PreviousLevel || result.SPDelta != 0 || result.HasExpertJob
|
||
skillStateChanged := result.NewLevel != result.PreviousLevel || result.SPDelta != 0 || result.HasExpertJob
|
||
if characterStateChanged {
|
||
characterBody := buildCurrentFinishLoadingCharacterStateBody(
|
||
result.PostCommitCharacter,
|
||
result.PostCommitSkill.Points,
|
||
)
|
||
if len(characterBody) != currentFinishLoadingCharacterStateBodySize {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentDungeonCharacterStateMsgID, characterBody, 0); err != nil {
|
||
return err
|
||
}
|
||
// 交任务升级同样要把客户端 last_level 拉平到新等级,否则下一次
|
||
// 场景 finalizer 会看到段差并自动补播章节动画。
|
||
//
|
||
// 唯一例外:本次任务开启了一个带过场的城镇区域。那条路径需要
|
||
// 保留「last_level 落在上一段」的段差,让客户端在本轮场景重建的
|
||
// 结尾播放该区域影片;章节动画的显式触发也在那里完成。
|
||
if result.NewLevel != result.PreviousLevel {
|
||
if _, _, _, opensCutscene := s.townMovieAreaOpenedByQuest(
|
||
0, result.QuestID, int64(result.PostCommitCharacter.Level), true); !opensCutscene {
|
||
if digestErr := s.sendCurrentStoryDigestLastLevelForLevelSync(
|
||
session,
|
||
uint32(result.PostCommitCharacter.Level),
|
||
"finish_quest_level_change_story_digest_sync_after_op37",
|
||
); digestErr != nil {
|
||
return digestErr
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if skillStateChanged {
|
||
// 副本内交任务同样会把 SP/经验一次性推上去(可能顺带升级)。场景内 op19 对
|
||
// 一次性 actor 被动职业(契魔者蛇腹剑等)会打掉武器形态,判据与副本内升级
|
||
// 完全一致:见 decideCurrentMidSceneSkillRefresh。SP/TP 已由上面的角色状态
|
||
// 包(op37)下发,跳过不丢信息。
|
||
decision := s.decideCurrentMidSceneSkillRefresh(result.PostCommitCharacter, result.PostCommitSkill)
|
||
if !decision.Send {
|
||
s.logGameEvent(session, "game-upper-finish-quest-character-skill-refresh-skipped",
|
||
"quest_id", result.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"character_id", result.PostCommitCharacter.CharacterID,
|
||
"level", result.PostCommitCharacter.Level,
|
||
"mode", decision.Mode,
|
||
"at_risk_skill_ids", fmt.Sprint(decision.AtRiskSkills),
|
||
"reason", decision.Reason)
|
||
} else if err := s.sendCurrentFinishQuestMidSceneSkillRefresh(session, result); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if !characterStateChanged && !skillStateChanged {
|
||
s.logGameEvent(session, "game-upper-finish-quest-character-skill-refresh-skipped",
|
||
"quest_id", result.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"reason", "no_experience_level_sp_or_expert_job_delta")
|
||
}
|
||
// Current EXE op574 rebuilds the role quest collection. Publish the active
|
||
// snapshot first, then apply op356 so completed-quest town gates are not
|
||
// discarded by that rebuild. op21 remains last because the op34 callback can
|
||
// immediately create the newly acceptable successor UI.
|
||
if err := s.sendCurrentActiveQuestSnapshotFromCommittedQuest(
|
||
session,
|
||
result.PostCommitQuest,
|
||
"current_exe_op34_after_atomic_finish_active_quest_manager_rebuild",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentClearQuestListFromCommittedQuest(
|
||
session,
|
||
result.PostCommitQuest,
|
||
"current_exe_op34_after_active_snapshot_completed_quest_scene_refresh",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
// The server never auto-accepts the successor; the client presents it from
|
||
// PVF and sends op31 only after player consent.
|
||
if err := s.sendCurrentAcceptableQuestListOnlyForSession(session, "current_exe_op34_after_active_snapshot_final"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendCurrentQuestGatedTownSceneRefresh(
|
||
session,
|
||
result,
|
||
"current_exe_op34_after_atomic_finish_task_snapshots",
|
||
)
|
||
}
|
||
|
||
// sendCurrentFinishQuestMidSceneSkillRefresh 下发交任务后的技能信息(op19)。调用方
|
||
// 必须先过 decideCurrentMidSceneSkillRefresh:对一次性 actor 被动职业跳过这次刷新,
|
||
// 否则副本内交任务升级会打掉契魔者的蛇腹剑形态。
|
||
func (s *Service) sendCurrentFinishQuestMidSceneSkillRefresh(
|
||
session *gameSession,
|
||
result dnfquest.FinishCommitResult,
|
||
) error {
|
||
if session == nil {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
activeTree, err := currentSceneActiveSkillTree(result.PostCommitCharacter)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
job, ok := characterJobByte(result.PostCommitCharacter)
|
||
if !ok {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
skillBody, activeSlots, err := s.buildCurrentSceneSkillInfoRuntimeBody(
|
||
ctx,
|
||
result.PostCommitCharacter,
|
||
result.PostCommitSkill,
|
||
job,
|
||
activeTree,
|
||
)
|
||
cancel()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// learned_skill_ids 与场景构建路径保持一致,方便日志核对这次刷新实际带了哪些技能。
|
||
activeState, found := dnfrepo.SkillTree(result.PostCommitSkill, int(activeTree))
|
||
if !found {
|
||
activeState = dnfrepo.SkillTreeState{
|
||
Skills: result.PostCommitSkill.Skills,
|
||
Points: result.PostCommitSkill.Points,
|
||
}
|
||
}
|
||
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]
|
||
})
|
||
projection := currentSceneSkillInfoProjection{
|
||
body: skillBody,
|
||
characterID: result.PostCommitCharacter.CharacterID,
|
||
job: job,
|
||
learnedCount: len(activeState.Skills),
|
||
learnedSkillIDs: learnedSkillIDs,
|
||
activeSlots: activeSlots,
|
||
activeTree: activeTree,
|
||
}
|
||
if job == darkcombo.Job {
|
||
return s.sendCurrentSceneSkillInfoProjectionWithTreeRestore(session, projection, "finish_quest_character_skill_refresh")
|
||
}
|
||
return s.sendCurrentSceneSkillInfoProjection(session, projection, "finish_quest_character_skill_refresh")
|
||
}
|
||
|
||
func (s *Service) sendCurrentFinishQuestSlotExpansionMode1Snapshot(
|
||
session *gameSession,
|
||
result dnfquest.FinishCommitResult) error {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
||
if !result.AtomicCommitted || !result.HasSlotExpansion || result.CharacterID != characterID ||
|
||
result.PostCommitCharacter.CharacterID != characterID {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
var legacyRepo dnfrepo.LegacyUserInfoRepository
|
||
if repositories, ok := s.repositoryGroup(); ok {
|
||
legacyRepo = repositories.LegacyUserInfo
|
||
}
|
||
charID := session.selectedCharacterID
|
||
summary := s.currentAccountAdventureGroupSummaryForPacket(ctx, session, result.PostCommitCharacter, true)
|
||
adventureLevel := uint32(summary.ManageLevel)
|
||
mode1Body := s.buildCurrentSelectedUserInfoMode1BodyWithAdventureLevelInContext(
|
||
ctx,
|
||
session,
|
||
legacyRepo,
|
||
result.PostCommitCharacter,
|
||
true,
|
||
charID,
|
||
adventureLevel,
|
||
currentTownActorOwnerContext(session),
|
||
)
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSetUDPIPPort), mode1Body, 0); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-upper-finish-quest-slot-expansion-mode1-send",
|
||
"quest_id", result.QuestID,
|
||
"mode1_body_len", len(mode1Body),
|
||
"owner_channel", currentTownActorOwnerContext(session),
|
||
"mode3_suppressed", true,
|
||
"ex_equip_slot_stat", result.PostCommitCharacter.Stats["ex_equip_slot_stat"],
|
||
"body_source", "atomic_post_commit_mode1_only_for_proved_slot_expansion_carrier")
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendCurrentFinishQuestRewardItemUpdate(session *gameSession, result dnfquest.FinishCommitResult) error {
|
||
if len(result.Items) == 0 && len(result.ConsumedItems) == 0 && len(result.Currency) == 0 {
|
||
s.logGameEvent(session, "game-upper-finish-quest-reward-item-update-skipped",
|
||
"quest_id", result.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"reason", "no_item_currency_or_consumed_material_changes")
|
||
return nil
|
||
}
|
||
entries := make([]currentItemListEntry, 0, len(result.Items)+len(result.ConsumedItems)+len(result.Currency))
|
||
seenGold := false
|
||
for _, currency := range result.Currency {
|
||
// 金币体只带 **PostValue**(快照余额),不带 Delta,所以这里的负 Delta 是合法的:
|
||
// 交付金币(PVF [int data] 的 ID 0,服主口径 2026-10-03)扣完钱后正是负 Delta。
|
||
// 早先这里判 `Delta < 0` 就整包拒绝,那会把"任务要求交 100 万金币"这条路径
|
||
// 在 ACK 阶段打回 shape 错误。钱包余额仍然必须非负。
|
||
if currency.PostValue < 0 {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(currency.Name)) {
|
||
case "gold":
|
||
if seenGold {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
seenGold = true
|
||
gold := currency.PostValue
|
||
if gold > math.MaxInt32 {
|
||
gold = math.MaxInt32
|
||
}
|
||
var entry currentItemListEntry
|
||
entry.patchCore(0, 0, uint32(gold))
|
||
entries = append(entries, entry)
|
||
default:
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
}
|
||
rewardSlots := make(map[string]struct{}, len(result.Items))
|
||
for _, item := range result.Items {
|
||
if item.SlotKey != "" {
|
||
rewardSlots[item.SlotKey] = struct{}{}
|
||
}
|
||
}
|
||
for _, consumed := range result.ConsumedItems {
|
||
if consumed.SlotIndex > math.MaxInt16 || consumed.ItemID <= 0 || consumed.ItemID > math.MaxUint32 || consumed.Delta <= 0 || consumed.Delta > math.MaxUint32 || consumed.PostCount < 0 {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
if _, replacedByReward := rewardSlots[consumed.SlotKey]; replacedByReward {
|
||
continue
|
||
}
|
||
var entry currentItemListEntry
|
||
if consumed.PostCount <= 0 {
|
||
entry.patchCore(int16(consumed.SlotIndex), math.MaxUint32, 0)
|
||
entries = append(entries, entry)
|
||
continue
|
||
}
|
||
if len(consumed.RawEntry) == currentItemListEntryWireSize &&
|
||
binary.LittleEndian.Uint16(consumed.RawEntry[0:2]) == consumed.SlotIndex &&
|
||
binary.LittleEndian.Uint32(consumed.RawEntry[2:6]) == uint32(consumed.ItemID) {
|
||
copy(entry.data[:], consumed.RawEntry)
|
||
entries = append(entries, entry)
|
||
continue
|
||
}
|
||
slots := result.PostCommitInventory.Slots
|
||
if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, int16(consumed.SlotIndex)) {
|
||
slots = result.PostCommitAccountInventory.Slots
|
||
}
|
||
stack, found := slots[consumed.SlotKey]
|
||
if !found || stack.ItemID != consumed.ItemID || stack.Count != consumed.PostCount {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
entry = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, int16(consumed.SlotIndex), stack)
|
||
entries = append(entries, entry)
|
||
}
|
||
for _, item := range result.Items {
|
||
if len(item.RawEntry) != currentItemListEntryWireSize ||
|
||
binary.LittleEndian.Uint16(item.RawEntry[0:2]) != item.SlotIndex ||
|
||
binary.LittleEndian.Uint32(item.RawEntry[2:6]) != uint32(item.ItemID) {
|
||
return errCurrentFinishQuestAckShape
|
||
}
|
||
var entry currentItemListEntry
|
||
copy(entry.data[:], item.RawEntry)
|
||
entries = append(entries, entry)
|
||
}
|
||
body := buildCurrentItemUpdateBody(0, entries)
|
||
s.logGameEvent(session, "game-upper-finish-quest-reward-item-update-send",
|
||
"quest_id", result.QuestID,
|
||
"completion_key", result.CompletionKey,
|
||
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
||
"classification", 0,
|
||
"list_type", 0,
|
||
"entry_count", len(entries),
|
||
"item_count", len(result.Items),
|
||
"consumed_count", len(result.ConsumedItems),
|
||
"currency_count", len(result.Currency),
|
||
"voucher_token_cera", result.VoucherCredits.TokenCera,
|
||
"voucher_happy_token_cera", result.VoucherCredits.HappyTokenCera,
|
||
"entry_size", currentItemListEntryWireSize,
|
||
"body_len", len(body),
|
||
"body_source", "committed_pvf_finish_reward_consumed_material_inventory_and_gold_op14_raw77")
|
||
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0)
|
||
}
|
||
|
||
func (s *Service) currentQuestFinishResources(ctx context.Context) (*progression.Tables, *pvfDungeonDropCatalog, error) {
|
||
if s == nil {
|
||
return nil, nil, dnfquest.ErrFinishSettlementUnavailable
|
||
}
|
||
s.initialEquipmentMu.Lock()
|
||
archive, err := s.initialEquipmentArchiveLocked()
|
||
s.initialEquipmentMu.Unlock()
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
tables, err := progression.Load(ctx, archive)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
var items *pvfDungeonDropCatalog
|
||
if s.dungeonMonsterTable != nil {
|
||
items, err = s.dungeonMonsterTable.DropCatalog()
|
||
} else {
|
||
items, err = newPVFDungeonDropCatalog(archive)
|
||
}
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return tables, items, nil
|
||
}
|
||
|
||
func currentQuestFinishItemAllocator(catalog *pvfDungeonDropCatalog, mainSlotCount uint16) dnfquest.FinishItemAllocator {
|
||
return func(record *dnfrepo.InventoryRecord, request dnfquest.FinishItemGrantRequest) (dnfquest.FinishCommittedItem, error) {
|
||
if catalog == nil || record == nil || request.ItemID <= 0 || request.ItemID > math.MaxUint32 || request.Count <= 0 || request.Count > math.MaxUint32 {
|
||
return dnfquest.FinishCommittedItem{}, errDungeonPickupItemInvalid
|
||
}
|
||
definition, err := catalog.ResolveItem(uint32(request.ItemID))
|
||
if err != nil {
|
||
return dnfquest.FinishCommittedItem{}, err
|
||
}
|
||
definition, err = currentPVFItemDefinitionForGrantAt(definition, time.Now().UTC())
|
||
if err != nil {
|
||
return dnfquest.FinishCommittedItem{}, err
|
||
}
|
||
source := strings.TrimSpace(request.Source)
|
||
if source == "" {
|
||
source = "quest_pvf_finish_reward"
|
||
}
|
||
// 账号共享优先:无色小晶块 / 灵魂 在同一个账号里只有账号共享仓那个家
|
||
// (唯一入口 grantCurrentAccountSharedFirst,见 account_shared_grant.go)。
|
||
//
|
||
// 只接受"整份都进得去":结算收据一行只能描述一个格子,而
|
||
// allocateFinishRewardItems 要求 Delta 就是整份数量 —— 拆行会让 PostCount
|
||
// 对不上 Delta,校验失败会把**整笔任务结算**打挂。所以固定格只能装一部分时
|
||
// 把那一格还原、整份改走下面的背包路径(与翻牌 P2-2 同一口径,绝不半进半留)。
|
||
// 线上这 12 个道具都没有 [stack limit],固定格不封顶,这条还原分支实际到不了。
|
||
if account := request.AccountInventory; account != nil {
|
||
if slot, member := dnfrepo.AccountSharedWarehouseSlotForItem(int64(definition.ItemID)); member {
|
||
key := dnfrepo.AccountSharedInventorySlotKey(slot)
|
||
before, existed := account.Slots[key]
|
||
shared := grantCurrentAccountSharedFirst(account, definition, uint32(request.Count), source)
|
||
if shared.Changed && shared.Remainder == 0 {
|
||
stack, found := account.Slots[key]
|
||
if !found || stack.ItemID != request.ItemID || stack.Count < request.Count ||
|
||
len(stack.RawEntry) != currentItemListEntryWireSize {
|
||
return dnfquest.FinishCommittedItem{}, errDungeonPickupItemInvalid
|
||
}
|
||
return dnfquest.FinishCommittedItem{
|
||
SlotKey: key, SlotIndex: uint16(shared.Slot), ItemID: request.ItemID,
|
||
Delta: request.Count, PostCount: stack.Count, CountOrSeed: uint32(request.Count),
|
||
// 账号共享槽也是 list-0 线地址,ACK 与刷新直接用这一行的 0x77 原文。
|
||
RawEntry: append([]byte(nil), stack.RawEntry...),
|
||
}, nil
|
||
}
|
||
if shared.Changed {
|
||
if existed {
|
||
account.Slots[key] = before
|
||
} else {
|
||
delete(account.Slots, key)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
definition = limitCurrentMainInventoryDefinitionSlots(definition, mainSlotCount)
|
||
occupied := make(map[string]struct{}, len(record.Slots))
|
||
for key := range record.Slots {
|
||
occupied[key] = struct{}{}
|
||
}
|
||
slot, err := addCurrentDungeonPickupToInventory(record, definition, uint32(request.Count))
|
||
if err != nil {
|
||
return dnfquest.FinishCommittedItem{}, err
|
||
}
|
||
key := currentDungeonPickupMainSlotKey(int16(slot))
|
||
stack, found := record.Slots[key]
|
||
if !found || stack.ItemID != request.ItemID || stack.Count < request.Count {
|
||
return dnfquest.FinishCommittedItem{}, errDungeonPickupItemInvalid
|
||
}
|
||
entry := currentItemListEntryFromStack(0, int16(slot), stack)
|
||
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
||
if stack.Extra == nil {
|
||
stack.Extra = make(map[string]string, 6)
|
||
}
|
||
if _, existed := occupied[key]; !existed {
|
||
stack.Extra["source"] = source
|
||
}
|
||
stack.Extra["last_grant_source"] = source
|
||
stack.Extra["last_grant_quest_id"] = strconv.FormatInt(request.QuestID, 10)
|
||
if completionKey := strings.TrimSpace(request.CompletionKey); completionKey != "" {
|
||
stack.Extra["last_grant_completion_key"] = completionKey
|
||
}
|
||
stack.Extra["last_grant_pvf_source"] = source
|
||
record.Slots[key] = stack
|
||
countOrSeed := uint32(request.Count)
|
||
if definition.Kind == dungeonDropItemEquipment {
|
||
countOrSeed = binary.LittleEndian.Uint32(entry.data[0x06:0x0a])
|
||
}
|
||
return dnfquest.FinishCommittedItem{
|
||
SlotKey: key, SlotIndex: slot, ItemID: request.ItemID,
|
||
Delta: request.Count, PostCount: stack.Count, CountOrSeed: countOrSeed,
|
||
RawEntry: append([]byte(nil), entry.data[:]...),
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// currentFinishGoldDebitOnly 报告这次结算的货币改动"**可以**放进链路包体":
|
||
// 空(没有货币改动)永远放行;非空时必须全是金币负 Delta(= 交付金币的扣款)。
|
||
//
|
||
// 完成包的包体本身不带货币:金币余额由 post-commit 的 op14 钱包行下发
|
||
// (见 sendCurrentFinishQuestRewardItemUpdate)。所以下面三条"链路包体"分支
|
||
// (职业变更 / 副职业 / 槽位扩展)过去一律要求 `len(Currency) == 0` 是合理的 —— 那时
|
||
// Currency 只可能来自金币**奖励**,而这三类任务不该有奖励。
|
||
//
|
||
// 但交付金币(PVF [int data] 的 ID 0,服主口径 2026-10-03)会给任何任务加上一条
|
||
// **负 Delta** 的货币条目,于是这三条链会把一笔完全正常的结算判成 ACK 形状非法:
|
||
// 现场 2026-10-03 开耳环槽位任务(2636,[slot expansion])正是这样 —— 金币扣了、
|
||
// 槽位开了,但完成回执发不出去,玩家要小退才看见"完成";重试又因为回执没落库而
|
||
// 报 receipt is corrupt。
|
||
//
|
||
// 判据收窄到"空,或**只**有扣款":任何正 Delta(真奖励)仍然被这三条链拒绝,
|
||
// 保持它们原有的"不该有奖励"约束不被顺手放开。
|
||
func currentFinishGoldDebitOnly(currency []dnfquest.FinishCommittedCurrency) bool {
|
||
for _, entry := range currency {
|
||
if !strings.EqualFold(strings.TrimSpace(entry.Name), "gold") || entry.Delta >= 0 || entry.PostValue < 0 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func buildCurrentFinishQuestSuccessBody(result dnfquest.FinishCommitResult) ([]byte, error) {
|
||
if !result.AtomicCommitted || result.QuestID <= 0 || result.QuestID > math.MaxUint16 ||
|
||
result.CompletionKey == "" || result.Source == "" || len(result.Items) > math.MaxUint8 || len(result.ConsumedItems) > math.MaxUint8 {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
var writer packetWriter
|
||
writer.writeUint16(uint16(result.QuestID))
|
||
writer.writeByte(0) // completionType=0: current reader consumes the normal item sections.
|
||
writer.writeUint32(result.ExperienceDelta)
|
||
writer.writeByte(byte(len(result.ConsumedItems)))
|
||
for _, consumed := range result.ConsumedItems {
|
||
if consumed.SlotIndex > math.MaxInt16 || consumed.ItemID <= 0 || consumed.ItemID > math.MaxUint32 ||
|
||
consumed.Delta <= 0 || consumed.Delta > math.MaxUint32 || consumed.PostCount < 0 {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
writer.writeByte(0) // updateType=0: current EXE subtracts consumed count from the slot.
|
||
writer.writeUint16(consumed.SlotIndex)
|
||
writer.writeUint32(uint32(consumed.Delta))
|
||
}
|
||
if result.HasProfession {
|
||
if len(result.Items) != 0 || !currentFinishGoldDebitOnly(result.Currency) ||
|
||
(result.Profession.ChainType != 1 && result.Profession.ChainType != 2) ||
|
||
result.Profession.GrowNumber == 0 || result.Profession.NewGrowType == result.Profession.PreviousGrowType ||
|
||
len(result.ProfessionGrants) > math.MaxUint8 {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
writer.writeByte(result.Profession.ChainType)
|
||
writer.writeByte(result.Profession.GrowNumber)
|
||
for tree := 0; tree < currentSkillInfoTreeCount; tree++ {
|
||
layout := result.PostCommitSkill.Layouts[tree]
|
||
writer.writeByte(byte(len(result.ProfessionGrants)))
|
||
for _, grant := range result.ProfessionGrants {
|
||
if grant.SkillID == 0 || grant.Level <= 0 || grant.Level > math.MaxUint8 {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
// Current NoPack sub_1D32120 consumes the destination skill-tree
|
||
// slot, the skill ID, then the absolute level, once per skill tree.
|
||
// The per-tree repetition only makes sense because the first byte is
|
||
// that tree's slot; a plain learned flag would be identical for both
|
||
// lists.
|
||
//
|
||
// Live 2026-09-15 (test1, quest 7803, 鬼剑士 -> 剑魂): the writer
|
||
// used to emit (level, skillID, 1), i.e. the slot and the level were
|
||
// swapped. Every grant therefore targeted tree slot 1 and the last one
|
||
// won the cell; the grants are sorted by skill ID, so 唤醒 (501, a
|
||
// [passive]) overwrote 上挑 in the asdfgh "s" cell. The client never
|
||
// places a passive on a quick bar and refuses to drag one off, so that
|
||
// cell was stuck until the next op19 rebuilt the manager. Writing the
|
||
// slot into the trailing byte instead only moved the bug: the client
|
||
// read the slot as the level and displayed 唤醒 at level 106. Send
|
||
// (slot, skillID, level).
|
||
slot, ok := finishProfessionGrantSlot(layout, grant.SkillID)
|
||
if !ok {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
writer.writeByte(slot)
|
||
writer.writeUint16(grant.SkillID)
|
||
writer.writeByte(byte(grant.Level))
|
||
}
|
||
}
|
||
return writer.bytes(), nil
|
||
}
|
||
if result.HasExpertJob {
|
||
if len(result.Items) != 0 || !currentFinishGoldDebitOnly(result.Currency) || result.ExpertJobType == 0 {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
// Current NoPack sub_1D32120 handles chain 20 in the same branch as
|
||
// class change/awakening: u8 type, then two inline skill-tree counts.
|
||
// Expert-job completion has no inline skill grants; the server persists
|
||
// expert_job_type atomically before this ACK is emitted.
|
||
writer.writeByte(20)
|
||
writer.writeByte(result.ExpertJobType)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
return writer.bytes(), nil
|
||
}
|
||
if result.HasSlotExpansion {
|
||
expectedBit, validIndex := dnfquest.ExEquipSlotBitForPVFIndex(result.SlotExpansionIndex)
|
||
if len(result.Items) != 0 || !currentFinishGoldDebitOnly(result.Currency) ||
|
||
!validIndex ||
|
||
result.SlotExpansionBit != expectedBit {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
// Current NoPack sub_1D32120 chain type 21 does read a u32, but the
|
||
// receiving model is unrelated to the support/magic-stone/earring
|
||
// bitset. The committed bit is projected by the slot-expansion-only
|
||
// mode1 actor snapshot, so finish uses the ordinary empty chain.
|
||
}
|
||
writer.writeByte(0) // chainType=0: ordinary PVF item reward.
|
||
writer.writeByte(byte(len(result.Items)))
|
||
for _, item := range result.Items {
|
||
if item.ItemID <= 0 || item.ItemID > math.MaxUint32 || item.CountOrSeed == 0 || len(item.RawEntry) != currentItemListEntryWireSize ||
|
||
binary.LittleEndian.Uint16(item.RawEntry[0:2]) != item.SlotIndex ||
|
||
binary.LittleEndian.Uint32(item.RawEntry[2:6]) != uint32(item.ItemID) {
|
||
return nil, errCurrentFinishQuestAckShape
|
||
}
|
||
writer.writeUint16(item.SlotIndex)
|
||
writer.writeUint32(uint32(item.ItemID))
|
||
writer.writeUint32(item.CountOrSeed)
|
||
writer.writeByte(item.RawEntry[0x0a])
|
||
writer.writeUint16(binary.LittleEndian.Uint16(item.RawEntry[0x0b:0x0d]))
|
||
writer.writeUint32(binary.LittleEndian.Uint32(item.RawEntry[0x0e:0x12]))
|
||
writer.writeByte(item.RawEntry[0x12])
|
||
writer.writeByte(item.RawEntry[0x13])
|
||
}
|
||
return writer.bytes(), nil
|
||
}
|
||
|
||
// finishProfessionGrantSlot reports the skill-tree slot the committed layout
|
||
// assigned to one granted skill. Every learned skill owns exactly one slot:
|
||
// skillcmd.EnsureLayout places whatever the class-change baseline granted and
|
||
// fails closed if it cannot, so a missing entry means the committed record and
|
||
// the grant list disagree and the ACK must not be emitted.
|
||
func finishProfessionGrantSlot(layout dnfrepo.SkillLayout, skillID uint16) (uint8, bool) {
|
||
for slot, assigned := range layout {
|
||
if assigned != skillID {
|
||
continue
|
||
}
|
||
if slot < 0 || slot > math.MaxUint8 {
|
||
return 0, false
|
||
}
|
||
return uint8(slot), true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func currentFinishQuestBusinessFailure(err error) bool {
|
||
return errors.Is(err, dnfquest.ErrFinishQuestNotPending) ||
|
||
errors.Is(err, dnfquest.ErrFinishQuestCompletionConflict) ||
|
||
errors.Is(err, dnfquest.ErrFinishQuestSettlementStale) ||
|
||
errors.Is(err, dnfquest.ErrFinishQuestSettlementCorrupt) ||
|
||
errors.Is(err, dnfquest.ErrFinishQuestMultiplierUnsupported) ||
|
||
errors.Is(err, dnfquest.ErrFinishProgressionUnproven) ||
|
||
errors.Is(err, dnfquest.ErrFinishRequiredItemsMissing) ||
|
||
errors.Is(err, dnfquest.ErrQuestDefinitionMissing) ||
|
||
errors.Is(err, dnfquest.ErrQuestNotAcceptable) ||
|
||
errors.Is(err, dnfquest.ErrQuestRewardUnsupported) ||
|
||
errors.Is(err, dnfquest.ErrQuestRewardMalformed) ||
|
||
errors.Is(err, dnfquest.ErrQuestRewardSelectionInvalid) ||
|
||
errors.Is(err, errDungeonPickupInventoryFull)
|
||
}
|