Files
90CNServer/server/internal/services/dnfbridge/game_aligned.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1469 lines
65 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本文件把已对齐的 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
}