服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1469 lines
65 KiB
Go
1469 lines
65 KiB
Go
// 本文件把已对齐的 DNF 旧客户端命令转交给模块化 handler。
|
||
// bridge 只负责分流和发包,不在这里补业务状态或伪造成功结果。
|
||
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/avatartitle"
|
||
"longheng.io/server/internal/modules/dnf/cargo"
|
||
"longheng.io/server/internal/modules/dnf/character"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||
dnfequip "longheng.io/server/internal/modules/dnf/equip"
|
||
"longheng.io/server/internal/modules/dnf/inventory"
|
||
"longheng.io/server/internal/modules/dnf/itemlock"
|
||
"longheng.io/server/internal/modules/dnf/mail"
|
||
"longheng.io/server/internal/modules/dnf/packageitem"
|
||
"longheng.io/server/internal/modules/dnf/party"
|
||
"longheng.io/server/internal/modules/dnf/pet"
|
||
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
|
||
"longheng.io/server/internal/modules/dnf/quest"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfskill "longheng.io/server/internal/modules/dnf/skill"
|
||
"longheng.io/server/internal/modules/dnf/skillcmd"
|
||
)
|
||
|
||
var errAlignedPostCommitProjectionDeferred = errors.New("aligned post-commit projection deferred")
|
||
|
||
var gameAlignedRegistry = alignedcmd.NewRegistry(
|
||
avatartitle.NewHandler(),
|
||
cargo.NewHandler(),
|
||
character.NewHandler(),
|
||
dungeoncmd.NewHandler(),
|
||
inventory.NewHandler(),
|
||
itemlock.NewHandler(),
|
||
mail.NewHandler(),
|
||
packageitem.NewHandler(),
|
||
party.NewHandler(),
|
||
pet.NewHandler(),
|
||
quest.NewHandler(),
|
||
raid.NewHandler(),
|
||
skillcmd.NewHandler(),
|
||
)
|
||
|
||
// handleAlignedGameCommand 把协议号已对齐、但尚未落业务实现的命令归入模块边界。
|
||
// 当前只记录分流证据,不伪造成功 ACK;具体回包必须等对应模块按 EXE/日志补齐。
|
||
func (s *Service) handleAlignedGameCommand(session *gameSession, cmd byte, typ uint16, body []byte) (bool, error) {
|
||
// 宠物蛋判据是进程级的、由运行时 PVF 证明的,装配一次即可;未装配时
|
||
// creaturestate 保持改动前的行为。
|
||
s.installPetEggProbeOnce()
|
||
if handled, err := s.handleCurrentAssaultCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
if handled, err := s.handleCurrentChatEmoticonCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
if typ == uint16(dnfenum.CmdPacketDeleteItem) &&
|
||
(currentSessionDungeonIsPractice(session) || currentSessionDungeonIsTrainingRoom(session)) {
|
||
// Practice and the training room both send the cube-delete command for
|
||
// skills that cost 无色小晶块. Acknowledge its empty protobuf result
|
||
// without mutating inventory.
|
||
return true, s.sendGameUpperRaw(session, typ, []byte{1, 4, 0, 0, 0, 0x08, 0, 0x18, 0})
|
||
}
|
||
// 无色小晶块消耗埋点(纯观测):放在练习场/修炼道场提前返回**之后**,
|
||
// 所以只统计真实副本里的技能消耗;这里只累加计数,不改回包、不碰背包。
|
||
if typ == uint16(dnfenum.CmdPacketDeleteItem) {
|
||
s.noteCurrentColorlessSpendFromCommand(session, body)
|
||
}
|
||
if typ == uint16(dnfenum.CmdPacketUseStackable) &&
|
||
(currentSessionDungeonIsPractice(session) ||
|
||
(currentSessionDungeonIsTrainingRoom(session) && currentDungeonTrainingRoomRefusesConsumables())) {
|
||
return true, s.sendGameUpperFailure(session, typ, 1)
|
||
}
|
||
if typ == uint16(dnfenum.CmdPacketCompoundItem) {
|
||
return true, s.handleCurrentCompoundRecipe(session, body)
|
||
}
|
||
if typ == uint16(dnfenum.CmdPacketUseStackable) {
|
||
if handled, err := s.handleCurrentExpertJobRecipeLearning(session, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
}
|
||
if handled, err := s.handleOnlineItemTradeCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
if handled, err := s.handleOnlineRaidCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
if handled, err := s.handleOnlineSocialCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
if handled, err := s.handleOnlinePartyCommand(session, typ, body); handled || err != nil {
|
||
return handled, err
|
||
}
|
||
repos, _ := s.repositoryGroup()
|
||
accountID := s.accountIDForSession(session)
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketMailboxOpen {
|
||
s.reconcileCurrentMailboxAttachmentExpirationsBeforeOpen(session)
|
||
}
|
||
var skillCatalog *dnfskill.Table
|
||
var initialSkillLevels map[uint16]int
|
||
var skillPointBaseline *dnfrepo.SkillPointState
|
||
var equipmentPlacement alignedcmd.EquipmentPlacementValidator
|
||
var guardianShieldResolver alignedcmd.GuardianShieldResolver
|
||
var petHatchResolver alignedcmd.PetHatchResolver
|
||
// dojoSkills 决定技能命令是否写进修炼道场自己的两页(内部页 2/3)。
|
||
var dojoSkills bool
|
||
if alignedSkillOwnerRulesRequired(dnfenum.CmdPacket(typ)) {
|
||
// Resolve the PVF-backed skill owner inputs under the same bounded
|
||
// lifetime as the mutation route. A previous op29 can be holding a
|
||
// repository write gate; using Background here made the next learn
|
||
// request wait forever before the handler had a chance to return the
|
||
// native failure ACK that releases the client's pending operation.
|
||
rulesBaseContext := context.Background()
|
||
if session != nil && session.ctx != nil {
|
||
rulesBaseContext = session.ctx
|
||
}
|
||
rulesContext, rulesCancel := context.WithTimeout(rulesBaseContext, createWriteTimeout)
|
||
skillCatalog, initialSkillLevels, skillPointBaseline = s.skillOwnerRules(rulesContext, repos, session.selectedCharacterID)
|
||
rulesCancel()
|
||
var dojoErr error
|
||
dojoSkills, dojoErr = s.currentPVPDojoSkillsActive(session)
|
||
if dojoErr != nil {
|
||
return true, dojoErr
|
||
}
|
||
}
|
||
petHatchResolver, petCatalogErr := s.alignedPetHatchResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if petCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-pet-hatch-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", petCatalogErr)
|
||
s.logInfo("dnfbridge pet hatch resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", petCatalogErr)
|
||
}
|
||
enchantBeadResolver, enchantCatalogErr := s.alignedEnchantBeadResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if enchantCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-enchant-bead-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", enchantCatalogErr)
|
||
s.logInfo("dnfbridge enchant bead resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", enchantCatalogErr)
|
||
}
|
||
monsterCardUpgradeResolver, monsterCardUpgradeCatalogErr := s.alignedMonsterCardUpgradeResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if monsterCardUpgradeCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-monster-card-upgrade-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", monsterCardUpgradeCatalogErr)
|
||
s.logInfo("dnfbridge monster card upgrade resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", monsterCardUpgradeCatalogErr)
|
||
}
|
||
amplifyItemResolver, amplifyItemCatalogErr := s.alignedAmplifyItemResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if amplifyItemCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-amplify-item-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", amplifyItemCatalogErr)
|
||
s.logInfo("dnfbridge amplify item resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", amplifyItemCatalogErr)
|
||
}
|
||
randomOptionResolver, randomOptionCatalogErr := s.alignedRandomOptionResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if randomOptionCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-random-option-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", randomOptionCatalogErr)
|
||
s.logInfo("dnfbridge random option resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", randomOptionCatalogErr)
|
||
}
|
||
equipmentRegenerationResolver, equipmentRegenerationCatalogErr := s.alignedEquipmentRegenerationResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if equipmentRegenerationCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-equipment-regeneration-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", equipmentRegenerationCatalogErr)
|
||
s.logInfo("dnfbridge equipment regeneration resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", equipmentRegenerationCatalogErr)
|
||
}
|
||
upgradeTicketResolver, upgradeTicketCatalogErr := s.alignedUpgradeTicketResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if upgradeTicketCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-upgrade-ticket-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", upgradeTicketCatalogErr)
|
||
s.logInfo("dnfbridge upgrade ticket resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", upgradeTicketCatalogErr)
|
||
}
|
||
upgradeProtectionResolver, upgradeProtectionCatalogErr := s.alignedUpgradeProtectionResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if upgradeProtectionCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-upgrade-protection-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", upgradeProtectionCatalogErr)
|
||
s.logInfo("dnfbridge upgrade protection resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", upgradeProtectionCatalogErr)
|
||
}
|
||
upgradePolicyResolver := s.withCurrentWornUpgradeAssist(
|
||
s.alignedUpgradePolicyResolver(), session, repos)
|
||
// A 公会徽章 (guild flag, list-38) upgrade takes its policy from the native
|
||
// flag_upgrade_table rather than the equipment upgrade table, so it replaces the
|
||
// resolver wholesale -- upstream swaps it in at exactly this point.
|
||
//
|
||
// The worn upgrade assist is deliberately NOT wrapped around it: that assist
|
||
// keys off equipment the character is wearing, and a flag sitting in the
|
||
// guild-medal list is not worn equipment.
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketUpgradeItem {
|
||
if medalRequest, decodeErr := inventory.DecodeUpgradeItemRequest(body); decodeErr == nil &&
|
||
medalRequest.TargetListType == currentGuildMedalInventoryListType {
|
||
upgradePolicyResolver = s.alignedGuildMedalUpgradePolicyResolver(uint32(medalRequest.TargetItemTemplateID))
|
||
}
|
||
}
|
||
upgradeRefundMailer := s.currentUpgradeRefundMailer()
|
||
forgingResolver, forgingCatalogErr := s.alignedForgingResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if forgingCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-forging-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", forgingCatalogErr)
|
||
s.logInfo("dnfbridge forging resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", forgingCatalogErr)
|
||
}
|
||
chronicleRefineResolver, chronicleRefineCatalogErr := s.alignedChronicleRefineResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if chronicleRefineCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-chronicle-refine-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", chronicleRefineCatalogErr)
|
||
s.logInfo("dnfbridge chronicle refine resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", chronicleRefineCatalogErr)
|
||
}
|
||
magicBoxResolver, magicBoxRewardResolver, magicBoxCatalogErr := s.alignedMagicBoxResolversForCommand(dnfenum.CmdPacket(typ))
|
||
magicBoxContractActivator := func(account *dnfrepo.AccountRecord, units map[int64]int64, now time.Time) error {
|
||
activation, activationErr := s.currentPremiumContractActivation(account, units, now)
|
||
if activationErr != nil {
|
||
// Returning the error makes the box owner place these units as
|
||
// ordinary rewards, so the reward stays recoverable as an item row.
|
||
s.logWarn("dnfbridge magic box contract activation fell back to ordinary placement",
|
||
"conn_id", session.connID,
|
||
"char_id", session.selectedCharacterID,
|
||
"units", fmt.Sprint(units),
|
||
"error", activationErr)
|
||
return activationErr
|
||
}
|
||
if activation.Changed() {
|
||
// The entitlement is committed with the box transaction; the client
|
||
// only needs the ordinary premium activation projection.
|
||
if err := s.sendCurrentPremiumInventoryActivations(session, activation, "aligned_magic_box_contract_direct_activation"); err != nil {
|
||
s.logWarn("dnfbridge magic box contract activation projection deferred",
|
||
"conn_id", session.connID,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
s.logGameEvent(session, "game-premium-contract-direct-activated",
|
||
"source", "aligned_magic_box_contract_direct_activation",
|
||
"char_id", session.selectedCharacterID,
|
||
"units", fmt.Sprint(units),
|
||
"activation_count", len(activation.Activations),
|
||
"activations", fmt.Sprint(activation.Activations))
|
||
}
|
||
return nil
|
||
}
|
||
if magicBoxCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-magic-box-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", magicBoxCatalogErr)
|
||
s.logInfo("dnfbridge magic box resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", magicBoxCatalogErr)
|
||
}
|
||
premiumContractResolver, premiumCatalogErr := s.alignedPremiumContractResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if premiumCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-premium-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", premiumCatalogErr)
|
||
s.logInfo("dnfbridge premium contract resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", premiumCatalogErr)
|
||
}
|
||
experienceConsumableResolver, experienceCatalogErr := s.alignedExperienceConsumableResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if experienceCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-experience-consumable-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", experienceCatalogErr)
|
||
s.logInfo("dnfbridge experience-consumable resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", experienceCatalogErr)
|
||
}
|
||
chronicleGrowthResolver, chronicleCatalogErr := s.alignedChronicleGrowthResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if chronicleCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-chronicle-growth-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", chronicleCatalogErr)
|
||
s.logInfo("dnfbridge chronicle growth resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", chronicleCatalogErr)
|
||
}
|
||
damageFontResolver, damageFontCatalogErr := s.alignedDamageFontResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if damageFontCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-damage-font-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", damageFontCatalogErr)
|
||
s.logInfo("dnfbridge damage-font resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", damageFontCatalogErr)
|
||
}
|
||
randomRewardItemResolver, randomRewardCatalogErr := s.alignedRandomRewardItemResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if randomRewardCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-random-reward-item-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", randomRewardCatalogErr)
|
||
s.logInfo("dnfbridge random-reward-item resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", randomRewardCatalogErr)
|
||
}
|
||
repairCostResolver, repairCostCatalogErr := s.alignedRepairCostResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if repairCostCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-repair-cost-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", repairCostCatalogErr)
|
||
s.logInfo("dnfbridge repair cost resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", repairCostCatalogErr)
|
||
}
|
||
mailboxItemResolver, mailboxCatalogErr := s.alignedMailboxItemResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if mailboxCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-mailbox-item-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", mailboxCatalogErr)
|
||
s.logInfo("dnfbridge mailbox item resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", mailboxCatalogErr)
|
||
}
|
||
stackMergeResolver, stackMergeCatalogErr := s.alignedStackMergeResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if stackMergeCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-stack-merge-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", stackMergeCatalogErr)
|
||
s.logInfo("dnfbridge stack-merge resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", stackMergeCatalogErr)
|
||
}
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketMoveItemspace {
|
||
if currentPetMoveTouchesGrowthState(body) {
|
||
if settleErr := s.settleCurrentPetGrowthClock(session, s.gameplayNow(), "current_exe_op19_before_pet_or_artifact_mutation"); settleErr != nil {
|
||
s.logPacketEvent("game-pet-satiety-before-op19-deferred",
|
||
"conn_id", session.connID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"reason", "growth_clock_settlement_failed",
|
||
"error", settleErr)
|
||
}
|
||
}
|
||
placementContext, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
validator, placementErr := s.newCurrentEquipmentPlacementValidator(placementContext, accountID, session.selectedCharacterID, repos)
|
||
cancel()
|
||
if placementErr != nil {
|
||
s.logPacketEvent("game-aligned-equipment-placement-validator-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", placementErr)
|
||
} else {
|
||
equipmentPlacement = func(ctx context.Context, placement alignedcmd.EquipmentPlacementRequest) error {
|
||
return validator.ValidateEquipmentPlacement(ctx, dnfequip.Placement{
|
||
CharacterID: placement.CharacterID,
|
||
ItemID: placement.ItemID,
|
||
SourceListType: placement.SourceListType,
|
||
SourceSlotIndex: placement.SourceSlotIndex,
|
||
TargetSlotIndex: placement.TargetSlotIndex,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketChangeDeckInfo ||
|
||
(dnfenum.CmdPacket(typ) == dnfenum.CmdPacketMoveItemspace && currentMoveTouchesGuardianShieldSpace(body)) {
|
||
resolverContext, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
resolver, resolverErr := s.newCurrentGuardianShieldResolver(resolverContext)
|
||
cancel()
|
||
if resolverErr != nil {
|
||
s.logPacketEvent("game-aligned-guardian-shield-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", resolverErr)
|
||
} else {
|
||
guardianShieldResolver = resolver
|
||
}
|
||
}
|
||
var petFeedSettlementErr error
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketUseStackable && currentPetConsumableUseTouchesGrowthState(body) {
|
||
petFeedSettlementErr = s.settleCurrentPetGrowthClock(
|
||
session,
|
||
s.gameplayNow(),
|
||
"current_exe_op44_before_pet_feed",
|
||
)
|
||
if petFeedSettlementErr != nil {
|
||
s.logPacketEvent("game-pet-feed-before-op44-deferred",
|
||
"conn_id", session.connID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"reason", "growth_clock_settlement_failed",
|
||
"error", petFeedSettlementErr)
|
||
}
|
||
}
|
||
stackableUseResolver, stackableUseCatalogErr := s.alignedStackableUseResolverForCommand(dnfenum.CmdPacket(typ))
|
||
if stackableUseCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-stackable-use-resolver-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", stackableUseCatalogErr)
|
||
s.logInfo("dnfbridge stackable-use resolver unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", stackableUseCatalogErr)
|
||
}
|
||
directExperiencePlanner, directExperienceCatalogErr := s.alignedDirectExperienceConsumablePlannerForCommand(dnfenum.CmdPacket(typ))
|
||
if directExperienceCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-direct-experience-planner-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", directExperienceCatalogErr)
|
||
s.logInfo("dnfbridge direct-experience planner unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", directExperienceCatalogErr)
|
||
}
|
||
questCompletionTicketPlanner, questCompletionTicketCatalogErr := s.alignedQuestCompletionTicketPlannerForCommand(dnfenum.CmdPacket(typ))
|
||
if questCompletionTicketCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-quest-completion-ticket-planner-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", questCompletionTicketCatalogErr)
|
||
s.logInfo("dnfbridge quest-completion-ticket planner unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", questCompletionTicketCatalogErr)
|
||
}
|
||
storyBookPlanner, storyBookCatalogErr := s.alignedStoryBookPlannerForCommand(dnfenum.CmdPacket(typ))
|
||
if storyBookCatalogErr != nil {
|
||
s.logPacketEvent("game-aligned-story-book-planner-unavailable",
|
||
"conn_id", session.connID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", storyBookCatalogErr)
|
||
s.logInfo("dnfbridge story-book planner unavailable",
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"error", storyBookCatalogErr)
|
||
}
|
||
if petFeedSettlementErr != nil && stackableUseResolver != nil {
|
||
baseResolver := stackableUseResolver
|
||
stackableUseResolver = func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||
resolution, err := baseResolver(itemID)
|
||
if err != nil {
|
||
return alignedcmd.StackableUseResolution{}, err
|
||
}
|
||
if normalizeDungeonDropStackableType(resolution.StackableType) == "[feed]" {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("settle pet growth before feed: %w", petFeedSettlementErr)
|
||
}
|
||
return resolution, nil
|
||
}
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
dungeonRuntime := session.dungeon.runtime
|
||
inDungeon := dungeonRuntime != nil
|
||
var dungeonID int64
|
||
if dungeonRuntime != nil {
|
||
dungeonID = dungeonRuntime.Dungeon.ID
|
||
}
|
||
session.dungeon.mu.Unlock()
|
||
session.party.mu.Lock()
|
||
routeBaseContext := context.Background()
|
||
if session.ctx != nil {
|
||
routeBaseContext = session.ctx
|
||
}
|
||
routeTimeout := createWriteTimeout
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketUseStackable {
|
||
routeTimeout = currentConsumableWriteTimeout
|
||
}
|
||
routeContext, routeCancel := context.WithTimeout(routeBaseContext, routeTimeout)
|
||
result, ok, err := gameAlignedRegistry.Route(routeContext, alignedcmd.Request{
|
||
Command: cmd,
|
||
CommandKnown: true,
|
||
Opcode: typ,
|
||
Body: body,
|
||
AccountID: accountID,
|
||
SelectedCharacterID: session.selectedCharacterID,
|
||
DojoSkills: dojoSkills,
|
||
Repositories: repos,
|
||
EquipmentPlacement: equipmentPlacement,
|
||
GuardianShield: guardianShieldResolver,
|
||
AvatarClassifier: func(itemID uint32) (bool, bool) { return s.currentClassifyAvatarItem(itemID) },
|
||
AuroraLookReplaceClassifier: func(itemID uint32) bool { return s.currentIsAuroraLookReplaceAvatar(itemID) },
|
||
StackMergeResolver: stackMergeResolver,
|
||
StackMergeResolverError: stackMergeCatalogErr,
|
||
AccountWarehouseSlotResolver: currentAccountWarehouseFixedSlot,
|
||
ItemCooltimeGroupResolver: s.currentPVFItemCooltimeGroup,
|
||
NameTagChecker: func(itemID uint32) bool { return s.currentIsNameTagItem(itemID) },
|
||
PetHatchResolver: petHatchResolver,
|
||
EnchantBeadResolver: enchantBeadResolver,
|
||
MonsterCardUpgradeResolver: monsterCardUpgradeResolver,
|
||
MonsterCardUpgradePolicy: s.currentMonsterCardUpgradePolicy(routeContext, repos, session),
|
||
AmplifyItemResolver: amplifyItemResolver,
|
||
RandomOptionResolver: randomOptionResolver,
|
||
EquipmentRegenerationResolver: equipmentRegenerationResolver,
|
||
UpgradeTicketResolver: upgradeTicketResolver,
|
||
UpgradeProtectionResolver: upgradeProtectionResolver,
|
||
UpgradePolicyResolver: upgradePolicyResolver,
|
||
UpgradeRefundMailer: upgradeRefundMailer,
|
||
ForgingResolver: forgingResolver,
|
||
ChronicleRefineResolver: chronicleRefineResolver,
|
||
MagicBoxResolver: magicBoxResolver,
|
||
MagicBoxRewardItemResolver: magicBoxRewardResolver,
|
||
MagicBoxContractActivator: magicBoxContractActivator,
|
||
RandomRewardItemResolver: randomRewardItemResolver,
|
||
PremiumContractResolver: premiumContractResolver,
|
||
ExperienceConsumableResolver: experienceConsumableResolver,
|
||
StackableUseResolver: stackableUseResolver,
|
||
FatiguePotionNow: s.gameplayNow(),
|
||
RentalPointNow: s.gameplayNow(),
|
||
DirectExperiencePlanner: directExperiencePlanner,
|
||
QuestCompletionTicketPlanner: questCompletionTicketPlanner,
|
||
StoryBookPlanner: storyBookPlanner,
|
||
ChronicleGrowthResolver: chronicleGrowthResolver,
|
||
InDungeon: inDungeon,
|
||
DungeonID: dungeonID,
|
||
ConsumableFree: currentSessionDungeonIgnoresConsumables(session),
|
||
DamageFontResolver: damageFontResolver,
|
||
DamageFontNow: s.gameplayNow(),
|
||
RepairCostResolver: repairCostResolver,
|
||
MailboxItemResolver: mailboxItemResolver,
|
||
SkillCatalog: skillCatalog,
|
||
InitialSkillLevels: initialSkillLevels,
|
||
SkillPointBaseline: skillPointBaseline,
|
||
Party: &session.party.state,
|
||
})
|
||
routeCancel()
|
||
session.party.mu.Unlock()
|
||
if err != nil || !ok {
|
||
return ok, err
|
||
}
|
||
decision := result.Decision
|
||
if !result.Handled {
|
||
return false, nil
|
||
}
|
||
switch decision.Action {
|
||
case alignedcmd.ActionPendingModule:
|
||
if typ == uint16(dnfenum.CmdPacketDeleteItem) && result.ResponseAllowed && len(result.UpperResponses) > 0 {
|
||
// A dungeon potion is spent as op18 delete + op38 cast. Record the
|
||
// committed delete so the cast arriving next can identify it.
|
||
s.noteCurrentConsumableDeleteFromCommand(session, body)
|
||
}
|
||
if result.ResponseAllowed && len(result.UpperResponses) > 0 {
|
||
s.logPacketEvent("game-aligned-command-real-response",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"cmd", cmd,
|
||
"type", typ,
|
||
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
|
||
"domain", string(decision.Domain),
|
||
"support", string(decision.Support),
|
||
"evidence", decision.Evidence,
|
||
"note", decision.Note,
|
||
"operation", result.Operation,
|
||
"response_count", len(result.UpperResponses),
|
||
"reason", result.Reason,
|
||
"body_len", len(body))
|
||
if err := s.sendAlignedUpperResponses(session, result); err != nil {
|
||
return true, err
|
||
}
|
||
return true, nil
|
||
}
|
||
// A parsed command can still be interactive even when its domain owner is
|
||
// incomplete or rejects the request before it can build a command-specific
|
||
// response. Returning "handled" without a packet leaves the current
|
||
// client waiting forever. The compatibility DLL publishes the exact
|
||
// class-1 response-handler bitmap for this EXE, so use that independent
|
||
// evidence to close only requests whose client really expects a response.
|
||
// The generic outer failure is deliberately not a fabricated business
|
||
// success: command-specific payload readers are skipped on failure.
|
||
if rejected, rejectErr := s.rejectUnsupportedInteractiveCommand(
|
||
session,
|
||
typ,
|
||
len(body),
|
||
"aligned_pending",
|
||
); rejected || rejectErr != nil {
|
||
return true, rejectErr
|
||
}
|
||
s.logPacketEvent("game-aligned-command-pending-module",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"cmd", cmd,
|
||
"type", typ,
|
||
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
|
||
"domain", string(decision.Domain),
|
||
"support", string(decision.Support),
|
||
"evidence", decision.Evidence,
|
||
"note", decision.Note,
|
||
"operation", result.Operation,
|
||
"response_allowed", result.ResponseAllowed,
|
||
"reason", result.Reason,
|
||
"body_len", len(body))
|
||
s.logInfo("dnfbridge aligned game command pending module implementation",
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
|
||
"domain", string(decision.Domain),
|
||
"support", string(decision.Support),
|
||
"operation", result.Operation,
|
||
"response_allowed", result.ResponseAllowed,
|
||
"reason", result.Reason,
|
||
"body_len", len(body))
|
||
return true, nil
|
||
case alignedcmd.ActionBlocked:
|
||
// Blocked migrations have the same client wait-state risk as an
|
||
// unimplemented module. Capability-gate the failure so passive reports
|
||
// remain silent and only proven interactive requests are released.
|
||
if rejected, rejectErr := s.rejectUnsupportedInteractiveCommand(
|
||
session,
|
||
typ,
|
||
len(body),
|
||
"aligned_blocked",
|
||
); rejected || rejectErr != nil {
|
||
return true, rejectErr
|
||
}
|
||
s.logPacketEvent("game-legacy-migration-blocked-by-evidence",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"cmd", cmd,
|
||
"type", typ,
|
||
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
|
||
"domain", string(decision.Domain),
|
||
"evidence", decision.Evidence,
|
||
"operation", result.Operation,
|
||
"reason", decision.Reason,
|
||
"body_len", len(body))
|
||
s.logInfo("dnfbridge legacy migration blocked by current exe evidence",
|
||
"account_id", accountID,
|
||
"selected_character_id", session.selectedCharacterID,
|
||
"type", typ,
|
||
"runtime_cmd_name", runtimeCmdPacketName(cmd, typ),
|
||
"domain", string(decision.Domain),
|
||
"operation", result.Operation,
|
||
"body_len", len(body))
|
||
return true, nil
|
||
default:
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
func currentSessionDungeonIsPractice(session *gameSession) bool {
|
||
if session == nil {
|
||
return false
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
defer session.dungeon.mu.Unlock()
|
||
return dungeonRuntimeOwnsCharacter(session.dungeon.runtime, session.selectedCharacterID) &&
|
||
currentDungeonRuntimeIsPractice(session.dungeon.runtime)
|
||
}
|
||
|
||
// currentSessionDungeonIsTrainingRoom reports whether the session's current run is
|
||
// 修炼道场 (5000). Live 2026-09-12: the client kept sending the skills' 无色 delete
|
||
// command (op18) and the consumable use command (op44) there and the server
|
||
// deducted both, while the room is supposed to consume nothing.
|
||
func currentSessionDungeonIsTrainingRoom(session *gameSession) bool {
|
||
if session == nil {
|
||
return false
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
defer session.dungeon.mu.Unlock()
|
||
return dungeonRuntimeOwnsCharacter(session.dungeon.runtime, session.selectedCharacterID) &&
|
||
currentDungeonIsTrainingRoom(session.dungeon.runtime.Dungeon)
|
||
}
|
||
|
||
// currentSessionDungeonIgnoresConsumables marks the sandbox runs whose
|
||
// consumables must not be deducted. Skill materials are handled by the caller
|
||
// (ack only); ordinary stackable use still runs for its effect and the owner
|
||
// keeps the stack count unchanged because this flag travels in the request.
|
||
func currentSessionDungeonIgnoresConsumables(session *gameSession) bool {
|
||
return currentSessionDungeonIsTrainingRoom(session)
|
||
}
|
||
|
||
func alignedSkillOwnerRulesRequired(opcode dnfenum.CmdPacket) bool {
|
||
switch opcode {
|
||
case dnfenum.CmdPacketChangeSkillslot, dnfenum.CmdPacketBuySkill, dnfenum.CmdPacketSkillInit,
|
||
dnfenum.CmdPacketUseStackable, dnfenum.CmdPacketIncreaseStatus:
|
||
// op30 SP/TP point books rebase every skill-tree ledger against the
|
||
// active PVF baseline, so they need the same owner rules as op44.
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *Service) skillOwnerRules(ctx context.Context, repos dnfrepo.Group, characterID uint16) (*dnfskill.Table, map[uint16]int, *dnfrepo.SkillPointState) {
|
||
if s == nil || repos.Character == nil || characterID == 0 {
|
||
return nil, nil, nil
|
||
}
|
||
record, ok, err := repos.Character.Load(ctx, fmt.Sprintf("%d", characterID))
|
||
if err != nil || !ok {
|
||
return nil, nil, nil
|
||
}
|
||
job, ok := characterJobByte(record)
|
||
if !ok {
|
||
return nil, nil, nil
|
||
}
|
||
profiles, catalog, err := s.currentProfessionResources(ctx)
|
||
if err != nil {
|
||
s.logInfo("dnfbridge profession skill rules unavailable", "character_id", characterID, "job", job, "error", err)
|
||
return nil, nil, nil
|
||
}
|
||
growTypeValue := numericCharacterStatValue(record, "grow_type")
|
||
if growTypeValue < 0 || growTypeValue > 0xff {
|
||
s.logInfo("dnfbridge profession grow type invalid", "character_id", characterID, "job", job, "grow_type", growTypeValue)
|
||
return nil, nil, nil
|
||
}
|
||
levels, ok := protectedProfessionSkillLevels(profiles, catalog, job, byte(growTypeValue), record.Level)
|
||
if !ok {
|
||
s.logInfo("dnfbridge profession free skill rules unavailable", "character_id", characterID, "job", job, "grow_type", growTypeValue, "error", dnfprofession.ErrProfileMissing)
|
||
return nil, nil, nil
|
||
}
|
||
// CONTRACT: this baseline is the ORDINARY PVF target (Etc/spTable.etc plus
|
||
// Etc/TP table through the character's level) and must NOT include the
|
||
// persisted bonus_sp/bonus_tp. The SP/TP book owner
|
||
// (inventory.useTPBook -> skillPointBookTarget) receives this same value as
|
||
// Request.SkillPointBaseline and adds the bonus itself, so folding the bonus
|
||
// in here made every book count the already-accumulated bonus twice and
|
||
// granted quadratic free SP.
|
||
//
|
||
// The skill-plan mutations that DO need the bonus (op29 buy_skill / op491
|
||
// skill reset) add it themselves from the character record in
|
||
// skillcmd.skillPointTargetForCharacter, which mirrors the bonus-inclusive
|
||
// currentPVFSkillPointTarget used by the login/reselect repair.
|
||
points, err := s.initialSkillPoints(ctx, record.Level)
|
||
if err != nil {
|
||
s.logInfo("dnfbridge skill point baseline unavailable", "character_id", characterID, "level", record.Level, "error", err)
|
||
return nil, nil, nil
|
||
}
|
||
baseline := &dnfrepo.SkillPointState{
|
||
TotalSP: points.TotalSP, RemainingSP: points.TotalSP,
|
||
TotalTP: points.TotalTP, RemainingTP: points.TotalTP,
|
||
SyncedLevel: points.SyncedLevel,
|
||
}
|
||
return catalog, levels, baseline
|
||
}
|
||
|
||
// sendAlignedUpperResponses 发送模块返回的真实 upper 响应。
|
||
// 模块给出的 Body 已经是完整业务包体,不能再统一套成功字节。
|
||
func (s *Service) sendAlignedUpperResponses(session *gameSession, result alignedcmd.Result) error {
|
||
s.recordAlignedCeraSpend(session, result)
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, result.Operation, result.PreResponseItemSlotRefreshes); err != nil {
|
||
return err
|
||
}
|
||
for _, response := range result.UpperResponses {
|
||
if err := s.sendGameUpperRawClassCodec(session, response.MsgID, response.Body, response.Classification, response.AllowCodec); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.sendAlignedDailyUseStates(session, result)
|
||
s.logAlignedItemCooltimeStates(session, result)
|
||
if err := s.sendSelectedEmptyItemSlotRefreshes(session, result.Operation, result.PostResponseItemSlotClears); err != nil {
|
||
return err
|
||
}
|
||
if result.CustomAbilityGroupConverter != nil {
|
||
session.customAbilityConverter.arm(*result.CustomAbilityGroupConverter, s.gameplayNow())
|
||
s.logGameEvent(session, "game-custom-ability-group-converter-opened",
|
||
"character_id", result.CustomAbilityGroupConverter.CharacterID,
|
||
"source_list", result.CustomAbilityGroupConverter.SourceListType,
|
||
"source_slot", result.CustomAbilityGroupConverter.SourceSlotIndex,
|
||
"source_item_id", result.CustomAbilityGroupConverter.SourceItemID,
|
||
"sequence", "op44_success_written_then_arm_op1340_continuation")
|
||
}
|
||
s.broadcastAlignedAreaResponses(session, result.Operation, result.AreaBroadcastResponses)
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, result.Operation, result.ItemSlotRefreshes); err != nil {
|
||
return err
|
||
}
|
||
// 原地改行的兜底:增量 op14 对"同槽同 item id、只有行内字段变化"的更新在这个
|
||
// 客户端上不重绘,只能整表重建。放在增量行之后,让权威快照最后落地。
|
||
for _, listType := range result.ItemListRebuilds {
|
||
if err := s.sendSelectedCurrentContainerListRefresh(
|
||
session, result.Operation+"_item_list_rebuild", listType); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendSelectedIncrementalEquipmentSlotRefreshes(session, result.Operation, result.EquipmentSlotRefreshes); err != nil {
|
||
return err
|
||
}
|
||
// 城镇增益药剂(史诗Buff药剂一族):把 effect-item 记录补发给客户端,否则它的
|
||
// buff 图标不会出现(现场 2026-10-03 服主截图)。这一族的 buff 由 PVF 的
|
||
// [probability buff list] 决定、必须服务端掷点,客户端自己算不出来。
|
||
if result.EffectItemInfo != nil {
|
||
body := buildCurrentEffectItemInfoBody(result.EffectItemInfo.ItemID, result.EffectItemInfo.RemainingSeconds)
|
||
s.logPacketEvent("game-aligned-effect-item-info-send",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"char_id", session.selectedCharacterID,
|
||
"operation", result.Operation,
|
||
"item_id", result.EffectItemInfo.ItemID,
|
||
"remaining_seconds", result.EffectItemInfo.RemainingSeconds,
|
||
"msg_id", currentLoadEffectItemInfoMsgID,
|
||
"classification", 0,
|
||
"body_len", len(body),
|
||
"sequence", "durable_owner_then_ack_then_item_row_then_class0_op174_load_effect_item_info")
|
||
if err := s.sendGameUpperRawClass(session, currentLoadEffectItemInfoMsgID, body, 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// buff 图标靠这一发:class0/op487 CHARACTER_ADD_BUFF(同族换档时先 op488 撤旧的,
|
||
// 再 op489 置激活)。现场 2026-10-03:只发 op174 时客户端只弹
|
||
// "[史诗Buff药剂]的效果时间还剩10分钟",buff 栏仍然空着 —— 因为通知号之前按参考
|
||
// 服务端 A21 的 486 基址推,在本客户端(基址 485)ADD_BUFF 实际是 487,等于一直
|
||
// 在发 DEL_BUFF。定标依据见 stat_change_potion_projection.go 顶部注释。
|
||
if result.CharacterAddBuff != nil && result.CharacterAddBuff.BuffID > 0 {
|
||
if removeID := result.CharacterAddBuff.RemoveBuffID; removeID > 0 && removeID != result.CharacterAddBuff.BuffID {
|
||
removeBody := buildCurrentCharacterRemoveBuffBody([]int32{removeID})
|
||
s.logPacketEvent("game-character-remove-buff-send",
|
||
"conn_id", session.connID,
|
||
"char_id", session.selectedCharacterID,
|
||
"operation", result.Operation,
|
||
"buff_id", removeID,
|
||
"msg_id", currentCharacterRemoveBuffMsgID,
|
||
"classification", 0,
|
||
"body_len", len(removeBody),
|
||
"sequence", "same_group_buff_replaced_before_add")
|
||
if err := s.sendGameUpperRawClass(session, currentCharacterRemoveBuffMsgID, removeBody, 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendCurrentCharacterAddBuff(session, result.CharacterAddBuff.BuffID,
|
||
"aligned_"+result.Operation+"_after_ack"); err != nil {
|
||
return err
|
||
}
|
||
// op487 不带时长,客户端不会自己摘图标 —— 排一个到期定时器,
|
||
// 到点补发 CHARACTER_DEL_BUFF + BUFF_DUNGEON(空)。见 stat_change_potion_expiry.go。
|
||
if expireAtUnix := result.CharacterAddBuff.ExpireAtUnix; expireAtUnix > 0 {
|
||
s.armCurrentStatChangePotionTimer(
|
||
session,
|
||
session.selectedCharacterID,
|
||
time.Unix(expireAtUnix, 0).UTC(),
|
||
"buff_registered_after_use",
|
||
)
|
||
}
|
||
}
|
||
if result.CharacterGoldAfter != nil {
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
||
buildCurrentGoldStateBody(*result.CharacterGoldAfter),
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(result.EpicBookBalances) > 0 {
|
||
rows := make([]currentEpicBookBalanceRow, 0, len(result.EpicBookBalances))
|
||
for _, balance := range result.EpicBookBalances {
|
||
rows = append(rows, currentEpicBookBalanceRow{
|
||
FragmentID: balance.FragmentID,
|
||
Balance: balance.Balance,
|
||
})
|
||
}
|
||
body, err := buildCurrentEpicBookInfoBody(1, rows)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentEpicBookInfoWireOpcode, body, 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, response := range result.PostItemSlotRefreshResponses {
|
||
if err := s.sendGameUpperRawClassCodec(session, response.MsgID, response.Body, response.Classification, response.AllowCodec); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if result.CeraWallet != nil {
|
||
if err := s.sendGameUpperRawClass(
|
||
session,
|
||
currentCeraShopUpdateMsgID,
|
||
buildCurrentCeraShopWalletBody(
|
||
result.CeraWallet.Cera,
|
||
result.CeraWallet.TokenCera,
|
||
result.CeraWallet.HappyTokenCera,
|
||
),
|
||
0,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if result.SeriaLuck != nil {
|
||
if err := s.sendCurrentSeriaLuckGauge(session, result.SeriaLuck.Before, result.SeriaLuck.After, "aligned_"+result.Operation+"_after_ack"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
refreshCombatPowerAffixes := false
|
||
for _, action := range result.PostActions {
|
||
if alignedPostActionTouchesCombatPower(action) {
|
||
refreshCombatPowerAffixes = true
|
||
}
|
||
// 2026-10-08:副本内的宠物外观发布改走 publishDungeonCreatureAppearanceChange
|
||
// (见 creature_appearance_publication.go)。发什么由开关文件决定,缺省不发。
|
||
//
|
||
// 现场证据:房内宠物 equip/swap/unequip 4/4 之后客户端再也发不出 op45(换房),
|
||
// 同局的武器 3/3、时装 3/3、无动作清房 1/1 全部正常 —— 包序列里唯一的差别就是
|
||
// 宠物这条分支补发的 op2/mode0 角色行。而
|
||
// 「初心-DNF轮回最终版/client-patch/README.md」写明 90CN 的设计原则是
|
||
// "does not destroy/recreate the local paper-doll or replay mode0/mode1",
|
||
// 所以候选换成客户端原生的「既有 actor 装备」通道 op342 + op357。
|
||
//
|
||
// 这个动作本身两种模式下都不跳过:用 continue 而不是让它落到下面去 break。
|
||
if action == alignedcmd.PostActionRefreshSelectedActorAppearance &&
|
||
currentDungeonSceneActive(session) &&
|
||
slices.Contains(result.PostActions, alignedcmd.PostActionRefreshSelectedCreatureState) {
|
||
if err := s.publishDungeonCreatureAppearanceChange(session, result.Operation); err != nil {
|
||
s.logWarn("dnfbridge dungeon creature appearance publication failed",
|
||
"conn_id", session.connID,
|
||
"operation", result.Operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
continue
|
||
}
|
||
if err := s.sendAlignedPostAction(session, result.Operation, action); err != nil {
|
||
if errors.Is(err, errAlignedPostCommitProjectionDeferred) {
|
||
s.logWarn("dnfbridge deferred remaining aligned post-commit projections",
|
||
"conn_id", session.connID,
|
||
"operation", result.Operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
break
|
||
}
|
||
return err
|
||
}
|
||
}
|
||
if refreshCombatPowerAffixes {
|
||
if err := s.sendSelectedCurrentCombatPowerAffixes(
|
||
session,
|
||
"aligned_"+result.Operation+"_after_ack_and_worn_state_commit",
|
||
); err != nil {
|
||
// The business mutation and ACK already succeeded. A private Lua
|
||
// projection failure is retryable on the next panel/town refresh and
|
||
// must not turn the committed item move into a false client failure.
|
||
s.logWarn("dnfbridge combat-power affix post-action deferred",
|
||
"conn_id", session.connID,
|
||
"operation", result.Operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
}
|
||
if result.Operation == "move_itemspace" || result.Operation == "sort_item" {
|
||
if err := s.sendSkillSwitchInventoryLocationRefresh(session, "aligned_"+result.Operation+"_after_ack_and_item_move"); err != nil {
|
||
// The durable item move and native ACK are already committed. The next
|
||
// scene/panel refresh can rebuild the same dynamic position table, so a
|
||
// transient projection failure must not turn this into a false op19
|
||
// failure or disconnect the selected character.
|
||
s.logWarn("dnfbridge skill-switch item-location refresh deferred",
|
||
"conn_id", session.connID,
|
||
"operation", result.Operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
}
|
||
if result.MailboxAlarmRecipientID != 0 {
|
||
if err := s.sendMailboxAlarmToOnlineRecipient(result.MailboxAlarmRecipientID); err != nil {
|
||
// The durable send and sender ACK have already succeeded. A stale
|
||
// recipient connection must not turn that committed operation into a
|
||
// false failure; their next op96 projects the same mailbox state.
|
||
s.logWarn("dnfbridge mailbox online alarm deferred to next mailbox open",
|
||
"recipient_character_id", result.MailboxAlarmRecipientID,
|
||
"operation", result.Operation,
|
||
"error", err)
|
||
}
|
||
}
|
||
if result.Operation == "enchant_by_bead" {
|
||
// Must run AFTER the ACK: the client zeroes the account crystal/soul cells
|
||
// synchronously while it handles that ACK (probe + disassembly 2026-09-13:
|
||
// slots 354..359, caller 0x01E80130 in sub_1E7FFD0) and the same handling
|
||
// rebuilds its container, so a pre-ACK snapshot is discarded (verified
|
||
// live). Replaying the login-shaped container snapshot afterwards restores
|
||
// the panel without any acquire toast; the transient blank is the client's
|
||
// own refresh and cannot be removed from the server side.
|
||
if err := s.sendSelectedCurrentContainerListsRefresh(session, "bead_enchant_after_ack"); err != nil {
|
||
s.logWarn("dnfbridge bead enchant container snapshot deferred",
|
||
"conn_id", session.connID,
|
||
"operation", result.Operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) broadcastAlignedAreaResponses(session *gameSession, operation string, responses []alignedcmd.UpperResponse) {
|
||
if s == nil || session == nil || s.onlinePlayers == nil || len(responses) == 0 {
|
||
return
|
||
}
|
||
source, ok := s.onlinePlayers.PlayerForCharacter(session.selectedCharacterID)
|
||
if !ok || source.Session != session {
|
||
s.logWarn("dnfbridge aligned town announcement skipped",
|
||
"operation", operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"reason", "requester_not_registered_in_current_town_area")
|
||
return
|
||
}
|
||
for _, player := range s.onlinePlayers.GetAreaInstancePlayers(
|
||
source.ChannelID, source.TownID, source.AreaID, source.InstanceKey) {
|
||
if player.Session == nil || player.CharacterID == source.CharacterID {
|
||
continue
|
||
}
|
||
for _, response := range responses {
|
||
if err := s.sendGameUpperRawClassCodec(player.Session, response.MsgID, response.Body, response.Classification, response.AllowCodec); err != nil {
|
||
s.logWarn("dnfbridge aligned town announcement send failed",
|
||
"operation", operation,
|
||
"source_char_id", source.CharacterID,
|
||
"recipient_char_id", player.CharacterID,
|
||
"msg_id", response.MsgID,
|
||
"error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func alignedPostActionTouchesCombatPower(action alignedcmd.PostAction) bool {
|
||
switch action {
|
||
case alignedcmd.PostActionRefreshSelectedItemContainers,
|
||
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
||
alignedcmd.PostActionRefreshSelectedActorExpandedEquipment,
|
||
alignedcmd.PostActionRefreshSelectedEquipmentSlots,
|
||
alignedcmd.PostActionRefreshSelectedWornCreatureInventory,
|
||
alignedcmd.PostActionRefreshSelectedCreatureState:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *Service) sendAlignedPostAction(session *gameSession, operation string, action alignedcmd.PostAction) error {
|
||
switch action {
|
||
case alignedcmd.PostActionRefreshSelectedItemContainers:
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketLeaveParty),
|
||
"classification", 0,
|
||
"list_types", currentSelectedItemListTypes,
|
||
"sequence", "durable_owner_then_class1_op19_ack_then_repository_backed_op13_list0_list1_list2")
|
||
if err := s.sendSelectedCurrentContainerListsWithRefresh(
|
||
session,
|
||
"aligned_"+operation+"_after_ack",
|
||
true,
|
||
); err != nil {
|
||
return fmt.Errorf("%w: refresh selected item containers: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedWornCreatureInventory:
|
||
// 宠物/宠物装备穿脱:只重建真正变化的 list 7,不重发 list 0/1/2/38
|
||
// (理由与实测见 alignedcmd 里该常量的注释)。收尾动作与五表刷新保持一致
|
||
// ——op13 重建会丢掉客户端进程内的锁标记,所以单表重建之后照旧补发排序锁
|
||
// 与 op251 物品锁快照。
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketLeaveParty),
|
||
"classification", 0,
|
||
"list_type", currentPetInventoryListType,
|
||
"sequence", "durable_owner_then_class1_op19_ack_then_repository_backed_op13_list7_only_then_sort_lock_then_op251")
|
||
if err := s.sendSelectedPetInventoryListRefresh(session, "aligned_"+operation+"_after_ack"); err != nil {
|
||
return fmt.Errorf("%w: refresh selected pet inventory: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
if err := s.sendCurrentSortLockReplayForSession(session, "aligned_"+operation+"_after_pet_inventory_refresh"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendCurrentSelectedItemLockSnapshot(session, "aligned_"+operation+"_after_pet_inventory_refresh")
|
||
case alignedcmd.PostActionResetCargoPadBeforeItemLists:
|
||
// 容量类操作(开通/升级账号金库、扩容券)之后必须重建仓库底板:当前 EXE 的
|
||
// 仓库/金库面板在创建时按容量铺格子,后续 op13 只更新行数据,不会把新解锁的
|
||
// 格子解锁(现场 2026-09-18:扩容后仍显示锁,切页才正常)。这里照进城镇那套
|
||
// 已验证顺序补一发——先 op391 重置 cargo pad,再由随后的列表重建面板。
|
||
if err := s.sendCurrentCargoPadResetBeforeItemLists(
|
||
session,
|
||
"aligned_"+operation+"_after_ack_before_real_item_lists",
|
||
); err != nil {
|
||
return fmt.Errorf("%w: reset cargo pad: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedPersonalCargo:
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketLeaveParty),
|
||
"classification", 0,
|
||
"list_type", currentEquipmentPlacementListPersonalCargo,
|
||
"sequence", "durable_owner_then_class1_op20_ack_then_repository_backed_op13_list2_only")
|
||
if err := s.sendSelectedCurrentContainerListRefresh(
|
||
session,
|
||
"aligned_"+operation+"_after_ack",
|
||
currentEquipmentPlacementListPersonalCargo,
|
||
); err != nil {
|
||
return fmt.Errorf("%w: refresh selected personal cargo: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedMainWallets:
|
||
if err := s.sendSelectedMainWalletsRefresh(
|
||
session,
|
||
"aligned_"+operation+"_after_ack",
|
||
); err != nil {
|
||
return fmt.Errorf("%w: refresh selected main wallets: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedEquipmentSlots:
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember),
|
||
"classification", 0,
|
||
"list_type", currentSocketListEquipment,
|
||
"sequence", "unsafe_op14_list3_equipment_rows_suppressed",
|
||
"evidence", "86jp_move_itemspace_sends_ack_sortlock_pet_specific_refresh_then_noti2_no_generic_list3")
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedEquippedCreatureItem:
|
||
if err := s.sendSelectedEquippedCreatureItemRefresh(session, "aligned_"+operation+"_after_ack"); err != nil {
|
||
return fmt.Errorf("%w: refresh equipped creature item: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedAccountCargo:
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
body, source, count, ok := s.buildCurrentItemListBodyForSession(ctx, session, 12)
|
||
if !ok {
|
||
return fmt.Errorf("aligned post action %q could not build account cargo", action)
|
||
}
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketLeaveParty),
|
||
"classification", 0,
|
||
"list_type", 12,
|
||
"entry_count", count,
|
||
"body_len", len(body),
|
||
"body_source", source,
|
||
"sequence", "durable_account_owner_then_class1_ack_then_current_exe_op13_list12_then_sort_lock_replay")
|
||
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), body); err != nil {
|
||
return err
|
||
}
|
||
// The list-12 rebuild destroys and recreates the vault's item rows, and the
|
||
// Ctrl+left-click padlock lives on the row rather than in the op251 lock
|
||
// list, so it has to be republished exactly like the character containers do.
|
||
// The equipment-lock snapshot is deliberately not resent here: list 12 is
|
||
// outside 86JP IsSupportedEquipmentLockListType and holds no equipment lock.
|
||
return s.sendCurrentSortLockReplayForSession(session, "aligned_"+operation+"_after_account_cargo_refresh")
|
||
case alignedcmd.PostActionRefreshSelectedActorAppearance:
|
||
// Full mode1 is reserved for rebuilding a new post-op24 actor generation;
|
||
// using it per click recreates the actor and blocks the next move.
|
||
if !currentTownSelfActorAppearanceReady(session) {
|
||
s.logWarn("dnfbridge deferred selected actor mode0 appearance",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"reason", "selected_town_actor_generation_or_native_owner_not_ready")
|
||
return fmt.Errorf(
|
||
"%w: selected town actor generation or native owner is not ready",
|
||
errAlignedPostCommitProjectionDeferred,
|
||
)
|
||
}
|
||
if err := s.sendSelectedActorMode0AppearanceRefresh(
|
||
session,
|
||
operation,
|
||
"durable_owner_then_class1_ack_then_repository_backed_native_owner_mode0",
|
||
); err != nil {
|
||
s.logWarn("dnfbridge deferred selected actor mode0 appearance",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
return fmt.Errorf("%w: refresh selected actor mode0 appearance: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
// The owner's own repaint is not enough: every client looking at this
|
||
// player keeps drawing the previous wardrobe until it is told.
|
||
s.broadcastTownActorAppearanceRefresh(session, "aligned_"+operation+"_after_ack")
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedActorExpandedEquipment:
|
||
if !currentTownSelfActorAppearanceReady(session) {
|
||
return fmt.Errorf(
|
||
"%w: selected town actor generation or native owner is not ready for expanded equipment",
|
||
errAlignedPostCommitProjectionDeferred,
|
||
)
|
||
}
|
||
source := "aligned_" + operation + "_after_ack"
|
||
if err := s.sendSelectedActorExpandedEquipmentRefresh(session, source+"_op342"); err != nil {
|
||
return fmt.Errorf("%w: refresh selected actor expanded equipment: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
if err := s.sendSelectedActorUserStateRefresh(session, source+"_op357_after_op342"); err != nil {
|
||
return fmt.Errorf("%w: redraw selected actor after expanded equipment: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
s.broadcastTownActorAppearanceRefresh(session, source+"_peer_repaint")
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedCreatureState:
|
||
if err := s.sendSelectedCreatureStateAfterMoveAck(session, "aligned_"+operation+"_after_ack"); err != nil {
|
||
// Creature state is an absolute post-ACK projection. Preserve the
|
||
// already-committed endpoint move and let the next scene bootstrap
|
||
// converge it instead of disconnecting after a successful ACK.
|
||
s.logWarn("dnfbridge deferred selected creature state after move",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"char_id", session.selectedCharacterID,
|
||
"error", err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedCreatureGrowthState:
|
||
if err := s.sendSelectedCreatureGrowthStateAfterFeed(session, "aligned_"+operation+"_after_ack"); err != nil {
|
||
return fmt.Errorf("%w: refresh selected creature growth: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedPetInventory:
|
||
if err := s.sendSelectedPetInventoryListRefresh(session, "aligned_"+operation+"_after_ack"); err != nil {
|
||
return fmt.Errorf("%w: refresh selected pet inventory: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshCrystalContractState:
|
||
return s.sendCurrentCrystalContractState(session, "aligned_"+operation+"_after_type97_activation")
|
||
case alignedcmd.PostActionRefreshSelectedDamageFontState:
|
||
return s.sendSelectedDamageFontState(session, "aligned_"+operation+"_after_ack_and_item_refresh")
|
||
case alignedcmd.PostActionRefreshSelectedPartyFrame:
|
||
identity, identityOK := s.boundGameSessionCharacterSnapshot(session)
|
||
var previous party.RuntimePartySnapshot
|
||
previousFound := false
|
||
if identityOK {
|
||
previous, previousFound = s.runtimePartyManagerForService().SnapshotByUser(identity.character, identity.generation)
|
||
}
|
||
state, err := s.ensureManagedRuntimePartyForSession(session)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if previousFound && previous.ID == uint16(state.PartyID) && len(previous.Members) == len(runtimePartyMembers(state)) {
|
||
return s.sendManagedRuntimePartyAggregateRefresh(state, "aligned_"+operation+"_party_settings")
|
||
}
|
||
if err := s.sendRuntimePartySnapshot(session, state); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedQuestState:
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Quest == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return fmt.Errorf("%w: quest repository unavailable", errAlignedPostCommitProjectionDeferred)
|
||
}
|
||
characterID := fmt.Sprintf("%d", session.selectedCharacterID)
|
||
record, found, err := repositories.Quest.Load(ctx, characterID)
|
||
if err != nil || !found {
|
||
return fmt.Errorf("%w: load committed quest record character=%s: %v", errAlignedPostCommitProjectionDeferred, characterID, err)
|
||
}
|
||
if err := s.sendCurrentClearQuestListFromCommittedQuest(session, record, "aligned_"+operation+"_after_op44_ack_and_source_refresh"); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendCurrentActiveQuestSnapshotForSession(session, "aligned_"+operation+"_after_completed_quest_bitmap"); err != nil {
|
||
return err
|
||
}
|
||
return s.sendCurrentAcceptableQuestListOnlyForSession(session, "aligned_"+operation+"_after_active_quest_snapshot")
|
||
case alignedcmd.PostActionRefreshSelectedActorSkills:
|
||
s.logPacketEvent("game-aligned-command-post-action-send",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", currentSkillInfoMsgID,
|
||
"classification", 0,
|
||
"sequence", "durable_skill_mutation_then_class1_ack_then_class0_op19_refresh")
|
||
return s.sendSelectedActorCurrentSceneSkillInfo(session, "aligned_"+operation+"_after_ack")
|
||
case alignedcmd.PostActionRefreshSelectedCharacterProgression:
|
||
return s.sendSelectedCharacterProgressionPostCommitState(session, "aligned_"+operation)
|
||
case alignedcmd.PostActionRefreshSelectedFatigue:
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return fmt.Errorf("%w: character repository unavailable", errAlignedPostCommitProjectionDeferred)
|
||
}
|
||
characterID, _, character, found := s.selectedCharacterForEnter(ctx, session)
|
||
if !found || characterID != session.selectedCharacterID {
|
||
return fmt.Errorf("%w: load committed fatigue character=%d", errAlignedPostCommitProjectionDeferred, session.selectedCharacterID)
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentFatigueMsgID, buildCurrentFatigueBody(character, true), 0); err != nil {
|
||
return fmt.Errorf("%w: send current fatigue: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionRefreshSelectedRentalWallet:
|
||
sent, err := s.sendSelectedCurrentRentalPointState(session, "aligned_"+operation+"_after_ack", true)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: send current rental wallet: %v", errAlignedPostCommitProjectionDeferred, err)
|
||
}
|
||
if !sent {
|
||
return fmt.Errorf("%w: rental wallet state deferred", errAlignedPostCommitProjectionDeferred)
|
||
}
|
||
return nil
|
||
case alignedcmd.PostActionNotifyRandomOptionGoldInsufficient:
|
||
return s.sendCurrentRandomOptionGoldInsufficientNotice(session, operation)
|
||
case alignedcmd.PostActionNotifyRandomOptionResetSuccess:
|
||
// op455 成功后补一条与品级调整(op81)同款的系统页提示:客户端自己的
|
||
// op455 处理器没有成功分支,不补就没有任何反馈。
|
||
return s.sendCurrentRandomOptionResetSuccessNotice(session)
|
||
case alignedcmd.PostActionSendDeleteItemAccountMaterialCompletion:
|
||
// 【不再发送 class-0/op18 空完成帧】
|
||
//
|
||
// class-0/op18 在本客户端的处理器是 sub_1D61130 —— 它就是【交易完成】
|
||
// 处理器(读 u16 条数 + 条数×物品对、清空交易管理器、然后创建交易结果
|
||
// 弹窗)。空体 {00 00} 会被读成「0 条结果的交易完成」,于是释放消耗无色
|
||
// 小晶块这类账号共享材料的技能时,客户端会弹出「物品交易已完成」——
|
||
// 与交易毫无关系的误提示。
|
||
//
|
||
// 该帧原本的假设("us90 用它清理库存忙状态")在本客户端不成立:账号共享
|
||
// 材料删除的完成由同一路径里的 class-1/op18 DELETE_ITEM RETURN
|
||
// (buildDungeonSkillMaterialDeleteAck)+ 随后的权威 op14 数量行负责。
|
||
// 而且 {00 00} 无法"改造得安全":交易完成处理器会把任何包体都当成条数读,
|
||
// 写非零值反而会让它去读取不存在的行。
|
||
//
|
||
// 因此这里只记录事件、不发包。若日后发现副本内技能连放会卡住库存忙状态,
|
||
// 应改为补发 class-1/op18 的合法空 RETURN,而不是恢复这一帧。
|
||
session.dungeon.mu.Lock()
|
||
inDungeon := session.dungeon.runtime != nil
|
||
session.dungeon.mu.Unlock()
|
||
s.logPacketEvent("game-aligned-command-post-action-suppressed",
|
||
"conn_id", session.connID,
|
||
"operation", operation,
|
||
"post_action", action,
|
||
"char_id", session.selectedCharacterID,
|
||
"msg_id", uint16(dnfenum.CmdPacketDeleteItem),
|
||
"classification", 0,
|
||
"in_dungeon", inDungeon,
|
||
"reason", "class0_op18_is_the_trade_finish_handler_and_would_show_a_false_trade_toast")
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("aligned post action %q is not registered", action)
|
||
}
|
||
}
|
||
|
||
// currentTownSelfActorAppearanceReady is stricter than the general scene-ready
|
||
// predicate. The in-place op357 redraw is allowed only for the already-finalized
|
||
// selected actor owned by the context committed for this town connection. This
|
||
// prevents an inventory action from targeting a remote/stale actor or crossing
|
||
// a town transition boundary.
|
||
func currentTownSelfActorAppearanceReady(session *gameSession) bool {
|
||
if session == nil || session.selectedCharacterID == 0 {
|
||
return false
|
||
}
|
||
session.townMu.Lock()
|
||
actorOwner := session.townActorOwnerChannel
|
||
connectionOwner := currentConnectionTownActorOwnerContext(session)
|
||
ready := session.townSceneReadyCharacterID == session.selectedCharacterID &&
|
||
(actorOwner == currentSceneObjectContext || actorOwner == connectionOwner)
|
||
session.townMu.Unlock()
|
||
if !ready {
|
||
return false
|
||
}
|
||
session.townPostTransition.mu.Lock()
|
||
stage := session.townPostTransition.stage
|
||
session.townPostTransition.mu.Unlock()
|
||
return stage == currentTownPostTransitionIdle ||
|
||
stage == currentTownPostTransitionComplete
|
||
}
|