服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
3228 lines
140 KiB
Go
3228 lines
140 KiB
Go
// Package inventory routes current-EXE inventory commands to repository-backed
|
||
// owners. Mutations return success only after the required transaction and
|
||
// authoritative ACK/refresh sequence have been constructed.
|
||
package inventory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfequip "longheng.io/server/internal/modules/dnf/equip"
|
||
dnfguardianshield "longheng.io/server/internal/modules/dnf/guardianshield"
|
||
dnfitemlock "longheng.io/server/internal/modules/dnf/itemlock"
|
||
"longheng.io/server/internal/modules/dnf/premium"
|
||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
||
)
|
||
|
||
type handler struct{}
|
||
|
||
// NewHandler 创建背包协议处理器。
|
||
func NewHandler() alignedcmd.Handler {
|
||
return handler{}
|
||
}
|
||
|
||
// Domain 返回背包业务域。
|
||
func (handler) Domain() dnfenum.AlignedDomain {
|
||
return dnfenum.AlignedDomainInventory
|
||
}
|
||
|
||
// Handle 解析已经对齐协议号的背包请求,并在关键写路径闭合前阻止 bridge 伪造成功 ACK。
|
||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||
switch dnfenum.CmdPacket(req.Opcode) {
|
||
case dnfenum.CmdPacketDeleteItem:
|
||
parsed, err := DecodeDeleteRequest(req.Body)
|
||
cmd := NewDeleteCommand(req, parsed)
|
||
if err != nil {
|
||
return ownerRejectedDeleteResult(req.Opcode, cmd, err), nil
|
||
}
|
||
if err := checkDeleteListType(cmd.SourceListType); err != nil {
|
||
return ownerRejectedDeleteResult(req.Opcode, cmd, err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerRejectedDeleteResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result, err := owner.Delete(ctx, cmd)
|
||
if err != nil {
|
||
return ownerRejectedDeleteResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedDeleteResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketMoveItemspace:
|
||
parsed, err := DecodeMoveItemspaceRequest(req.Body)
|
||
cmd := NewMoveCommand(req, parsed)
|
||
if err != nil {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
}
|
||
if dnfguardianshield.IsWireList(cmd.SourceListType) || dnfguardianshield.IsWireList(cmd.DestinationListType) {
|
||
if cmd.MoveCount != 0 || cmd.SourceInstanceValue < 0 || cmd.DestinationInstance != 0 || cmd.DestinationStack != 0 ||
|
||
(cmd.SourceListType == dnfguardianshield.WireListEquipped && cmd.SourceInstanceValue != 0) ||
|
||
(cmd.SourceListType == dnfguardianshield.WireListCatalog && cmd.SourceInstanceValue == 0) {
|
||
return ownerRejectedGuardianShieldMoveResult(req.Opcode, cmd, fmt.Errorf("invalid current guardian shield op19 scalar fields")), nil
|
||
}
|
||
if req.GuardianShield == nil {
|
||
return ownerRejectedGuardianShieldMoveResult(req.Opcode, cmd, fmt.Errorf("guardian shield PVF resolver is unavailable")), nil
|
||
}
|
||
owner, ownerErr := dnfguardianshield.NewOwner(req.Repositories, req.GuardianShield)
|
||
if ownerErr != nil {
|
||
return ownerRejectedGuardianShieldMoveResult(req.Opcode, cmd, ownerErr), nil
|
||
}
|
||
result, ownerErr := owner.Move(ctx, dnfguardianshield.MoveCommand{
|
||
CharacterID: req.SelectedCharacterID,
|
||
AccountID: req.AccountID,
|
||
SourceListType: cmd.SourceListType,
|
||
SourceSlotIndex: cmd.SourceSlotIndex,
|
||
SourceShieldItemID: uint32(cmd.SourceInstanceValue),
|
||
DestinationListType: cmd.DestinationListType,
|
||
DestinationSlotIndex: cmd.DestinationSlotIndex,
|
||
})
|
||
if ownerErr != nil {
|
||
return ownerRejectedGuardianShieldMoveResult(req.Opcode, cmd, ownerErr), nil
|
||
}
|
||
return ownerAppliedGuardianShieldMoveResult(req.Opcode, cmd, result), nil
|
||
}
|
||
if req.StackMergeResolverError != nil && moveTouchesMainQuickSlot(cmd) {
|
||
return ownerRejectedQuickSlotCharmMoveResult(
|
||
req.Opcode,
|
||
cmd,
|
||
fmt.Errorf("%w: %v", ErrQuickSlotCharmValidationUnavailable, req.StackMergeResolverError),
|
||
), nil
|
||
}
|
||
petEquipmentMove := isProvenPetEquipmentMove(cmd)
|
||
if petEquipmentMove {
|
||
if err := validateProvenPetEquipmentMove(cmd); err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
var owner *dnfequip.Owner
|
||
if cmd.DestinationSlotIndex == 26 {
|
||
owner, err = dnfequip.NewOwner(req.Repositories)
|
||
} else if req.EquipmentPlacement == nil {
|
||
return ownerBlockedResult(cmd, fmt.Errorf("pet artifact move requires the current PVF placement validator")), nil
|
||
} else {
|
||
owner, err = dnfequip.NewOwnerWithPlacementValidator(req.Repositories, dnfequip.PlacementValidatorFunc(func(ctx context.Context, placement dnfequip.Placement) error {
|
||
return req.EquipmentPlacement(ctx, alignedcmd.EquipmentPlacementRequest{
|
||
CharacterID: placement.CharacterID,
|
||
ItemID: placement.ItemID,
|
||
SourceListType: placement.SourceListType,
|
||
SourceSlotIndex: placement.SourceSlotIndex,
|
||
TargetSlotIndex: placement.TargetSlotIndex,
|
||
})
|
||
}))
|
||
}
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
ownerCommand := normalizedEquipmentMoveCommand(cmd)
|
||
// Current NoPack uses actor-worn endpoint 3 or 17 on the wire.
|
||
// Both resolve to the same durable EquipmentRecord. Keep the raw
|
||
// endpoint in cmd for request evidence while the ACK reports the
|
||
// owner's committed list-3 endpoint.
|
||
result, err := owner.Move(ctx, ownerCommand)
|
||
if err != nil {
|
||
return ownerRejectedMoveRequestResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedPetEquipmentMoveResult(req.Opcode, cmd, result), nil
|
||
}
|
||
if (cmd.SourceListType == listTypePet || cmd.DestinationListType == listTypePet) &&
|
||
!(cmd.SourceListType == listTypePet && cmd.DestinationListType == listTypePet) {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), fmt.Errorf("unsupported current-EXE pet move endpoints")), nil
|
||
}
|
||
if isActorWornEndpoint(cmd.SourceListType) || isActorWornEndpoint(cmd.DestinationListType) {
|
||
var owner *dnfequip.Owner
|
||
if req.EquipmentPlacement == nil {
|
||
return ownerBlockedResult(cmd, fmt.Errorf("normal equipment move requires the current PVF placement validator")), nil
|
||
} else {
|
||
owner, err = dnfequip.NewOwnerWithPlacementValidator(req.Repositories, dnfequip.PlacementValidatorFunc(func(ctx context.Context, placement dnfequip.Placement) error {
|
||
return req.EquipmentPlacement(ctx, alignedcmd.EquipmentPlacementRequest{
|
||
CharacterID: placement.CharacterID,
|
||
ItemID: placement.ItemID,
|
||
SourceListType: placement.SourceListType,
|
||
SourceSlotIndex: placement.SourceSlotIndex,
|
||
TargetSlotIndex: placement.TargetSlotIndex,
|
||
})
|
||
}))
|
||
}
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
owner.SetAvatarClassifier(req.AvatarClassifier)
|
||
owner.SetAuroraLookReplaceClassifier(req.AuroraLookReplaceClassifier)
|
||
owner.SetNameTagChecker(req.NameTagChecker)
|
||
result, err := owner.Move(ctx, normalizedEquipmentMoveCommand(cmd))
|
||
if err != nil {
|
||
return ownerRejectedMoveRequestResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedEquipmentMoveResult(req.Opcode, cmd, result), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
owner.SetStackMergeResolver(req.StackMergeResolver)
|
||
owner.SetAccountWarehouseSlotResolver(req.AccountWarehouseSlotResolver)
|
||
result, err := owner.Move(ctx, cmd)
|
||
if err != nil {
|
||
if errors.Is(err, ErrQuickSlotCharmAlreadyEquipped) {
|
||
return ownerRejectedQuickSlotCharmMoveResult(req.Opcode, cmd, err), nil
|
||
}
|
||
if errors.Is(err, ErrMoveDestinationFull) || errors.Is(err, ErrMoveCarryLimitExceeded) {
|
||
return ownerRejectedMoveSpaceResult(req.Opcode, cmd, err), nil
|
||
}
|
||
// 2026-09-18 现场:把道具拖进账号金库**未解锁**的栏位(dst 12:28、容量 8)时
|
||
// owner 以 ErrAccountCargoSlotOutOfRange 拒绝,旧代码落到 ownerBlockedResult
|
||
// 不回帧,客户端 sub_1D2DE80 的 pending 拖拽对象永远不释放。已解析成 move 的
|
||
// 请求被 owner 拒绝,必须回失败帧——见 ownerRejectedMoveRequestResult。
|
||
return ownerRejectedMoveRequestResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedMoveResult(ctx, req.Repositories.Settings, req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketChangeDeckInfo:
|
||
deck, err := decodeCurrentGuardianShieldDeckRequest(req.Body)
|
||
if err != nil {
|
||
return rejectedGuardianShieldDeckResult(req.Opcode, err), nil
|
||
}
|
||
if req.GuardianShield == nil {
|
||
return rejectedGuardianShieldDeckResult(req.Opcode, fmt.Errorf("guardian shield PVF resolver is unavailable")), nil
|
||
}
|
||
owner, err := dnfguardianshield.NewOwner(req.Repositories, req.GuardianShield)
|
||
if err != nil {
|
||
return rejectedGuardianShieldDeckResult(req.Opcode, err), nil
|
||
}
|
||
result, err := owner.SaveDeck(ctx, req.SelectedCharacterID, req.AccountID, deck)
|
||
if err != nil {
|
||
return rejectedGuardianShieldDeckResult(req.Opcode, err), nil
|
||
}
|
||
return appliedGuardianShieldDeckResult(req.Opcode, result), nil
|
||
case dnfenum.CmdPacketSortItem:
|
||
parsed, err := DecodeSortItemRequest(req.Body)
|
||
cmd := NewSortCommand(req, parsed)
|
||
if err != nil {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.Sort(ctx, cmd)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedSortResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketBuyItem:
|
||
parsed, err := DecodeBuyItemRequest(req.Body)
|
||
cmd := NewBuyCommand(req, parsed)
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
case dnfenum.CmdPacketSellItem:
|
||
parsed, err := DecodeDeleteOrSellRequest(req.Body)
|
||
cmd := NewSellCommand(req, parsed)
|
||
if err != nil {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.Sell(ctx, cmd)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedSellResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketRepairEquipment:
|
||
parsed, err := DecodeRepairEquipmentRequest(req.Body)
|
||
cmd := NewRepairCommand(req, parsed)
|
||
if err != nil {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
}
|
||
if cmd.SourceSlotIndex == -1 {
|
||
// 全部修理 (86JP TryRepairAll): 穿戴装备 + 主列表快捷栏 slot 3..8,
|
||
// 合计费用一次扣除,ACK 槽位 0xFFFF,随后发布实际变更行。
|
||
if cmd.SourceListType != listTypeEquipment && cmd.SourceListType != listTypeMain {
|
||
return ownerBlockedResult(cmd, fmt.Errorf("%w: %d", ErrUnsupportedList, cmd.SourceListType)), nil
|
||
}
|
||
owner, err := dnfequip.NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.RepairAll(ctx, dnfequip.RepairCommand{
|
||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||
AccountID: req.AccountID,
|
||
SlotIndex: cmd.SourceSlotIndex,
|
||
QuickRepair: cmd.QuickRepair,
|
||
AutoRepair: cmd.AutoRepair,
|
||
}, req.RepairCostResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedRepairAllResult(req.Opcode, cmd, result), nil
|
||
}
|
||
if cmd.SourceListType == listTypeEquipment {
|
||
owner, err := dnfequip.NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.Repair(ctx, dnfequip.RepairCommand{
|
||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||
AccountID: req.AccountID,
|
||
SlotIndex: cmd.SourceSlotIndex,
|
||
QuickRepair: cmd.QuickRepair,
|
||
AutoRepair: cmd.AutoRepair,
|
||
}, req.RepairCostResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedEquippedRepairResult(req.Opcode, cmd, result), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.Repair(ctx, cmd, req.RepairCostResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedRepairResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketDisjointItem:
|
||
parsed, err := DecodeDisjointItemRequest(req.Body)
|
||
cmd := NewDisjointCommand(req, parsed)
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
case dnfenum.CmdPacketRequestEventServerLevelUp:
|
||
parsed, err := DecodeEventServerLevelUpRequest(req.Body)
|
||
cmd := NewEventServerLevelUpCommand(req, parsed)
|
||
if err != nil {
|
||
return rejectedEventServerLevelUpResult(req.Opcode, cmd, err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return rejectedEventServerLevelUpResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result, err := owner.UseEventServerLevelUp(
|
||
ctx,
|
||
cmd,
|
||
req.StackableUseResolver,
|
||
req.DirectExperiencePlanner,
|
||
req.QuestCompletionTicketPlanner,
|
||
req.InDungeon,
|
||
)
|
||
if err != nil {
|
||
return rejectedEventServerLevelUpResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return appliedEventServerLevelUpResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketIncreaseStatus:
|
||
parsed, err := DecodeIncreaseStatusRequest(req.Body)
|
||
cmd := NewIncreaseStatusCommand(req, parsed)
|
||
if err != nil {
|
||
return rejectedIncreaseStatusResult(req.Opcode, cmd, err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return rejectedIncreaseStatusResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result, err := owner.UseIncreaseStatusBook(
|
||
ctx,
|
||
cmd,
|
||
req.StackableUseResolver,
|
||
req.DirectExperiencePlanner,
|
||
req.SkillCatalog,
|
||
req.InitialSkillLevels,
|
||
req.SkillPointBaseline,
|
||
)
|
||
if err != nil {
|
||
return rejectedIncreaseStatusResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return appliedIncreaseStatusResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUseStackable:
|
||
parsed, err := DecodeUseStackableRequest(req.Body)
|
||
cmd := NewUseStackableCommand(req, parsed)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
if err := checkUseStackableRequestListType(cmd.SourceListType); err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
experienceResolver := req.ExperienceConsumableResolver
|
||
if experienceResolver != nil {
|
||
resolution, resolveErr := experienceResolver(int64(cmd.ItemTemplateID))
|
||
if resolveErr != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, fmt.Errorf("%w: %v", ErrExperienceConsumableResolveFailed, resolveErr)), nil
|
||
}
|
||
if resolution.BonusPercent > 0 {
|
||
if !req.InDungeon {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, ErrExperienceConsumableDungeonRequired), nil
|
||
}
|
||
experienceResolver = func(int64) (alignedcmd.ExperienceConsumableResolution, error) {
|
||
return resolution, nil
|
||
}
|
||
}
|
||
}
|
||
stackableResolver := req.StackableUseResolver
|
||
dailyUseLimit := int64(0)
|
||
cooltimeSeconds := int64(0)
|
||
cooltimeGroup := int64(0)
|
||
maintainsCooltime := false
|
||
if stackableResolver != nil {
|
||
resolution, resolveErr := stackableResolver(int64(cmd.ItemTemplateID))
|
||
if resolveErr != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, fmt.Errorf("%w: %v", ErrQuestCompletionTicketResolveFailed, resolveErr)), nil
|
||
}
|
||
if resolution.QuestCompletionTicket != nil && resolution.QuestCompletionTicket.VillageOnly && req.InDungeon {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, ErrQuestCompletionTicketContract), nil
|
||
}
|
||
// PVF [total usable count] of ANY stackable consumable: the number
|
||
// the client itself prints as 每天可用次数. Zero means "no cap".
|
||
dailyUseLimit = resolution.DailyUseLimit
|
||
// PVF [cool time] + [cooltime maintenance]: only items whose
|
||
// cooldown must survive a relog get a durable window (see
|
||
// inventory.Owner.ReserveItemCooltime).
|
||
maintainsCooltime = resolution.MaintainsCooltime
|
||
cooltimeSeconds = resolution.CooltimeSeconds
|
||
cooltimeGroup = resolution.CooltimeGroup
|
||
stackableResolver = func(int64) (alignedcmd.StackableUseResolution, error) {
|
||
return resolution, nil
|
||
}
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
// Reserve the daily slot before the use commits: a rejection at the cap
|
||
// must leave the source stack untouched, and a failed use must give the
|
||
// slot back. 修炼道场 sandbox runs consume nothing, so they never reserve.
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
reservation := DailyItemUseReservation{}
|
||
if !cmd.ConsumableFree {
|
||
reservation, err = owner.ReserveDailyItemUse(
|
||
ctx, characterID, int64(cmd.ItemTemplateID), dailyUseLimit, time.Time{})
|
||
if err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
}
|
||
// 冷却(PVF [cool time] + [cooltime maintenance]):用过就在窗口内拒绝,并在同一
|
||
// 事务里落一份到期时间,这样小退重上仍然被拦(客户端的冷却门只在内存里)。同
|
||
// [cooltime group] 的道具共用一个窗口,所以检查要用能解析**已存在冷却行**的
|
||
// group 解析器,而不是只看本次这一件。
|
||
cooltime := ItemCooltimeReservation{}
|
||
if !cmd.ConsumableFree && maintainsCooltime && cooltimeSeconds > 0 {
|
||
cooltime, err = owner.ReserveItemCooltime(
|
||
ctx, characterID, int64(cmd.ItemTemplateID), cooltimeSeconds, cooltimeGroup,
|
||
req.ItemCooltimeGroupResolver, time.Time{})
|
||
if err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
}
|
||
result, err := owner.UseStackableWithAllResolversAndSkillCatalog(
|
||
ctx,
|
||
cmd,
|
||
req.PremiumContractResolver,
|
||
experienceResolver,
|
||
stackableResolver,
|
||
req.QuestCompletionTicketPlanner,
|
||
req.DirectExperiencePlanner,
|
||
req.StoryBookPlanner,
|
||
req.SkillCatalog,
|
||
req.InitialSkillLevels,
|
||
req.SkillPointBaseline,
|
||
)
|
||
if err != nil {
|
||
if releaseErr := owner.ReleaseDailyItemUse(ctx, characterID, reservation, time.Time{}); releaseErr != nil {
|
||
err = fmt.Errorf("%w (daily-use slot release failed: %v)", err, releaseErr)
|
||
}
|
||
if releaseErr := owner.ReleaseItemCooltime(ctx, characterID, cooltime, time.Time{}); releaseErr != nil {
|
||
err = fmt.Errorf("%w (item cooltime release failed: %v)", err, releaseErr)
|
||
}
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result.DailyUseItemID = reservation.ItemID
|
||
result.DailyUseUsed = reservation.Used
|
||
result.DailyUseLimit = reservation.Limit
|
||
result.CooltimeItemID = cooltime.ItemID
|
||
result.CooltimeExpireAt = cooltime.ExpireAt
|
||
// 防虚弱药剂: the client applies the buff itself and never reports it, so
|
||
// the server records the immunity window from the item id alone. Applied
|
||
// after the use, so the effect can never exist without the consumption.
|
||
if _, err := owner.ApplyWeaknessPotion(ctx, characterID, int64(cmd.ItemTemplateID), time.Time{}); err != nil {
|
||
return ownerRejectedUseStackableResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedUseStackableResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUseSharedEffectItem:
|
||
parsed, err := DecodeUseSharedEffectItemRequest(req.Body)
|
||
cmd := NewUseSharedEffectItemCommand(req, parsed)
|
||
if err != nil {
|
||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.UseSharedEffectItem(ctx, cmd, req.RandomRewardItemResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedSharedEffectItemResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUseStackableAction:
|
||
parsed, err := DecodeUseStackableActionRequest(req.Body)
|
||
cmd := NewUseStackableActionCommand(req, parsed)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
// 每日可用次数(PVF [total usable count])与 op44 同一条规则:动作落地之前
|
||
// 先按道具**自己的**声明预留一次,动作没成功就还回去。上限属于道具、与走哪个
|
||
// action 无关,所以预留放在分派之前(见 stackable_action_daily_use.go)。
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
reservation, err := owner.reserveUseStackableActionDailyUse(
|
||
ctx, cmd, req.StackableUseResolver, characterID)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
applied, committed := owner.applyUseStackableAction(ctx, cmd, req)
|
||
if !committed {
|
||
if releaseErr := owner.ReleaseDailyItemUse(ctx, characterID, reservation, time.Time{}); releaseErr != nil {
|
||
applied.Reason = fmt.Sprintf("%s (daily-use slot release failed: %v)", applied.Reason, releaseErr)
|
||
}
|
||
}
|
||
// 只有真正预留过的道具才投影 op541。action 58(疲劳药)在自己的事务里计数并
|
||
// 已经带上 DailyUseStates,这里绝不覆盖它。
|
||
if states := dailyUseStatesFromReservation(reservation); len(states) > 0 {
|
||
applied.DailyUseStates = states
|
||
}
|
||
return applied, nil
|
||
case dnfenum.CmdPacketUpgradeInventory, dnfenum.CmdPacketUpgradeCargo:
|
||
// 扩容券(op430 物品栏 / op408 金库)。请求体只有一个 i16 槽位,券的身份由
|
||
// owner 从持久行 + 运行时 PVF `[action type]` 反查,客户端说什么不算数。
|
||
parsed, err := DecodeContainerUpgradeRequest(req.Body)
|
||
cmd := NewContainerUpgradeCommand(req, parsed)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
containerKind := alignedcmd.ContainerUpgradeInventory
|
||
if dnfenum.CmdPacket(req.Opcode) == dnfenum.CmdPacketUpgradeCargo {
|
||
containerKind = alignedcmd.ContainerUpgradeCargo
|
||
}
|
||
result, err := owner.UpgradeContainerFromVoucher(
|
||
ctx, cmd, req.StackableUseResolver, containerKind, time.Time{})
|
||
if err != nil {
|
||
return ownerRejectedUseStackableActionResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedContainerUpgradeResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketSelectDamageFontSkin:
|
||
parsed, err := DecodeSelectDamageFontRequest(req.Body)
|
||
cmd := NewSelectDamageFontCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("select damage font parse failed: %v", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildSelectDamageFontErrorAck(damageFontSelectionErrorUnavailable)),
|
||
},
|
||
}, nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.SelectDamageFont(ctx, cmd, req.DamageFontNow)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedDamageFontSelectionResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUpgradeItem:
|
||
parsed, err := DecodeUpgradeItemRequest(req.Body)
|
||
cmd := NewUpgradeCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("upgrade_item parse failed: %v; returned current-EXE op50 error ACK", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildUpgradeItemErrorAck(upgradeErrorInvalidTarget)),
|
||
},
|
||
}, nil
|
||
}
|
||
var portableTool *alignedcmd.UpgradeTicketResolution
|
||
if upgradeTicketTargetListSupported(cmd) && req.UpgradeTicketResolver != nil && !upgradeMaterialSlotIsAccountShared(cmd) {
|
||
if materialItemID := upgradeMaterialItemIDOfSlot(req.Repositories, ctx, cmd); materialItemID > 0 {
|
||
resolution, resolveErr := req.UpgradeTicketResolver(materialItemID, int64(cmd.TargetItemTemplateID))
|
||
if resolveErr != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("portable/ticket PVF resolution failed: %v", resolveErr),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildUpgradeItemErrorAck(upgradeErrorInvalidMaterial))},
|
||
}, nil
|
||
}
|
||
if resolution.PortableAmplifier || resolution.PortableReinforcer {
|
||
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") || resolution.TargetUpgradeForbidden {
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: "portable amplifier rejected by target PVF",
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildUpgradeItemErrorAck(upgradeErrorInvalidTarget))},
|
||
}, nil
|
||
}
|
||
portableTool = &resolution
|
||
cmd.UpgradePortableAmplifier = resolution.PortableAmplifier
|
||
cmd.UpgradePortableReinforcer = resolution.PortableReinforcer
|
||
cmd.UpgradePortableMinimum = resolution.PortableMinimumLevel
|
||
cmd.UpgradePortableMaximum = resolution.PortableMaximumLevel
|
||
cmd.UpgradeTargetMinimumLevel = resolution.TargetMinimumLevel
|
||
cmd.UpgradeTargetLevelMinimum = resolution.TargetLevelMinimum
|
||
cmd.UpgradeTargetLevelMaximum = resolution.TargetLevelMaximum
|
||
cmd.UpgradeMaterialItemID = int(materialItemID)
|
||
cmd.UpgradeMaterialCount = 1
|
||
}
|
||
}
|
||
}
|
||
if upgradeTicketTargetListSupported(cmd) && req.UpgradeTicketResolver != nil && portableTool == nil && !upgradeMaterialSlotIsAccountShared(cmd) {
|
||
ticketOwner, ownerErr := NewOwner(req.Repositories)
|
||
if ownerErr != nil {
|
||
return ownerBlockedResult(cmd, ownerErr), nil
|
||
}
|
||
ticketResult, ticketErr := ticketOwner.UpgradeTicket(ctx, cmd, req.UpgradeTicketResolver)
|
||
if ticketErr != nil {
|
||
return ownerBlockedResult(cmd, ticketErr), nil
|
||
}
|
||
if ticketResult.TicketResolved {
|
||
return ownerAppliedUpgradeTicketResult(ctx, req.Repositories.Settings, req.Opcode, cmd, ticketResult), nil
|
||
}
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
owner.SetStackMergeResolver(req.StackMergeResolver)
|
||
owner.SetUpgradeProtectionResolver(req.UpgradeProtectionResolver)
|
||
owner.SetUpgradeRefundMailer(req.UpgradeRefundMailer)
|
||
targetStack, _ := upgradeTargetStackOfSlot(req.Repositories, ctx, cmd)
|
||
targetAmplifyType, _ := upgradeAmplifyState(targetStack)
|
||
// PVF 强化策略解析失败必须拒绝这一次强化:成功率、失败惩罚、金币和材料口径
|
||
// 全在这一份策略里。旧行为是把 UpgradeSuccessWeight 留成 0 继续往下走,owner
|
||
// 侧把它回落成「必成」,而材料口径因为 requiredMaterialItemID==0 退化成
|
||
// 「玩家点的那一格随便什么道具扣 1 个」(owner.go:2179 分支)—— 于是一次策略
|
||
// 表缺行就等于全局永久 100% 强化成功 + 不扣对材料。
|
||
//
|
||
// 只拦「解析器在、但解析失败」这一种。解析器整个缺失(nil)在线上不会发生
|
||
// (dnfbridge 的 alignedUpgradePolicyResolver 恒装配),而 op50 的既有单测
|
||
// 依赖 nil == 走旧路径,所以这里刻意保持原样不动。
|
||
if req.UpgradePolicyResolver != nil {
|
||
policy, policyErr := req.UpgradePolicyResolver(cmd.Mode, int(upgradeLevelOf(targetStack)), int64(cmd.TargetItemTemplateID), int(targetAmplifyType))
|
||
if policyErr != nil {
|
||
cmd.UpgradePolicyError = policyErr.Error()
|
||
return ownerAppliedUpgradeResult(ctx, req.Repositories.Settings, req.Opcode, cmd,
|
||
UpgradeResult{ErrorCode: upgradeErrorInvalidTarget}), nil
|
||
}
|
||
cmd.UpgradeSuccessWeight = policy.SuccessWeight
|
||
cmd.UpgradePenaltyType = policy.PenaltyType
|
||
cmd.UpgradePenaltyValue = policy.PenaltyValue
|
||
cmd.UpgradeGoldCost = policy.GoldCost
|
||
cmd.UpgradeGoldCostResolved = true
|
||
cmd.UpgradeMaterialItemID = policy.MaterialItemID
|
||
cmd.UpgradeMaterialCount = policy.MaterialCount
|
||
cmd.UpgradeDestroyBonusItemID = policy.DestroyBonusItemID
|
||
cmd.UpgradeDestroyBonusCount = policy.DestroyBonusCount
|
||
cmd.UpgradeDestroyExtraRewards = append([]alignedcmd.UpgradeDestroyReward(nil), policy.DestroyExtraRewards...)
|
||
cmd.UpgradeNoticeLevel = policy.NoticeLevel
|
||
// The two guild-medal discriminators: the resolver flags a list-38
|
||
// 公会徽章 attempt and reports the flag level its table row came
|
||
// from, so the owner can re-check both against the stored row.
|
||
cmd.UpgradeGuildMedal = policy.GuildMedal
|
||
cmd.UpgradePolicyLevel = policy.CurrentLevel
|
||
applyUpgradeHighUpgradeTerms(&cmd)
|
||
} else {
|
||
cmd.UpgradePolicyError = "upgrade policy resolver missing"
|
||
}
|
||
if portableTool != nil {
|
||
cmd.UpgradeMaterialItemID = int(upgradeMaterialItemIDOfSlot(req.Repositories, ctx, cmd))
|
||
cmd.UpgradeMaterialCount = 1
|
||
}
|
||
result, err := owner.Upgrade(ctx, cmd)
|
||
if err != nil {
|
||
if cmd.TargetListType == listTypeGuildMedal {
|
||
// The medal page waits on an op50 class1 ACK. ownerBlockedResult
|
||
// answers with the generic blocked form, which the medal window
|
||
// does not read, so a failed transaction would leave it spinning;
|
||
// upstream answers the same list-38 failure with the ordinary
|
||
// upgrade error ACK instead.
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("guild medal upgrade transaction failed: %v", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildUpgradeItemErrorAck(upgradeErrorInvalidTarget))},
|
||
}, nil
|
||
}
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedUpgradeResult(ctx, req.Repositories.Settings, req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUpgradeChronicle:
|
||
parsed, err := DecodeChronicleGrowthRequest(req.Body)
|
||
cmd := NewChronicleGrowthCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("upgrade_chronicle parse failed: %v", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildChronicleGrowthErrorAck(1))},
|
||
}, nil
|
||
}
|
||
if req.ChronicleGrowthResolver == nil {
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("upgrade_chronicle blocked: %v", ErrChronicleResolverRequired),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildChronicleGrowthErrorAck(0x13))},
|
||
}, nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
growth, err := owner.UpgradeChronicle(ctx, cmd, req.ChronicleGrowthResolver)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true, ResponseAllowed: true, Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("upgrade_chronicle rejected: %v", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(req.Opcode, buildChronicleGrowthErrorAck(0x13))},
|
||
}, nil
|
||
}
|
||
return ownerAppliedChronicleGrowthResult(req.Opcode, cmd, growth), nil
|
||
case dnfenum.CmdPacketUpgradeItemSeparate:
|
||
parsed, err := DecodeSeparateUpgradeRequest(req.Body)
|
||
cmd := NewSeparateUpgradeCommand(req, parsed)
|
||
if err != nil {
|
||
return forgingRejectedResult(req.Opcode, cmd, fmt.Sprintf("request parse failed: %v", err)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return forgingRejectedResult(req.Opcode, cmd, err.Error()), nil
|
||
}
|
||
result, err := owner.Forge(ctx, cmd, req.ForgingResolver)
|
||
if err != nil {
|
||
return forgingRejectedResult(req.Opcode, cmd, err.Error()), nil
|
||
}
|
||
return ownerAppliedForgingResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketEnchant3rdChronicleItem:
|
||
parsed, err := DecodeChronicleRefineRequest(req.Body)
|
||
cmd := NewChronicleRefineCommand(req, parsed)
|
||
if err != nil {
|
||
return chronicleRefineRejectedResult(req.Opcode, cmd, chronicleRefineErrorInvalidMaterial, fmt.Sprintf("request parse failed: %v", err)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return chronicleRefineRejectedResult(req.Opcode, cmd, chronicleRefineErrorInvalidMaterial, err.Error()), nil
|
||
}
|
||
result, err := owner.RefineChronicle(ctx, cmd, req.ChronicleRefineResolver)
|
||
if err != nil {
|
||
return chronicleRefineRejectedResult(req.Opcode, cmd, result.ErrorCode, err.Error()), nil
|
||
}
|
||
return ownerAppliedChronicleRefineResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketEnchantByBead:
|
||
parsed, err := DecodeEnchantByBeadRequest(req.Body)
|
||
cmd := NewEnchantCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("enchant_by_bead parse failed: %v; returned current-EXE 0x0110 error ACK", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildEnchantByBeadErrorAck(enchantErrorInvalidBead)),
|
||
},
|
||
}, nil
|
||
}
|
||
if req.EnchantBeadResolver == nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("enchant_by_bead blocked: runtime-PVF resolver unavailable; refusing to trust request metadata: %v", ErrEnchantResolverRequired),
|
||
}, nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.Enchant(ctx, cmd, req.EnchantBeadResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedEnchantResult(ctx, req.Repositories.Settings, req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUpgradeCard:
|
||
parsed, err := DecodeMonsterCardUpgradeRequest(req.Body)
|
||
cmd := NewMonsterCardUpgradeCommand(req, parsed)
|
||
if err != nil {
|
||
return rejectedMonsterCardUpgradeResult(req.Opcode, cmd, monsterCardUpgradeErrorInvalid, err), nil
|
||
}
|
||
if req.MonsterCardUpgradeResolver == nil {
|
||
return rejectedMonsterCardUpgradeResult(req.Opcode, cmd, monsterCardUpgradeErrorInvalid, ErrMonsterCardUpgradeResolverRequired), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return rejectedMonsterCardUpgradeResult(req.Opcode, cmd, monsterCardUpgradeErrorInvalid, err), nil
|
||
}
|
||
result, err := owner.UpgradeMonsterCard(ctx, cmd, req.MonsterCardUpgradeResolver, req.MonsterCardUpgradePolicy)
|
||
if err != nil {
|
||
return rejectedMonsterCardUpgradeResult(req.Opcode, cmd, monsterCardUpgradeErrorInvalid, err), nil
|
||
}
|
||
return ownerAppliedMonsterCardUpgradeResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketPurifyItem:
|
||
parsed, err := DecodePurifyItemRequest(req.Body)
|
||
cmd := NewPurifyItemCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("purify_item parse failed: %v; returned current-EXE op204 fixed failure ACK", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildPurifyItemErrorAck()),
|
||
},
|
||
}, nil
|
||
}
|
||
if req.AmplifyItemResolver == nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("purify_item blocked: runtime-PVF resolver unavailable: %v", ErrAmplifyItemResolverRequired),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildPurifyItemErrorAck()),
|
||
},
|
||
}, nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.PurifyAmplifyItem(ctx, cmd, req.AmplifyItemResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedPurifyItemResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketInvestItemAmplifyOption:
|
||
parsed, err := DecodeInvestItemAmplifyOptionRequest(req.Body)
|
||
cmd := NewInvestItemAmplifyOptionCommand(req, parsed)
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("invest_item_amplify_option parse failed: %v; returned current-EXE op205 error ACK", err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildInvestItemAmplifyOptionErrorAck(investAmplifyErrorInvalid)),
|
||
},
|
||
}, nil
|
||
}
|
||
if req.AmplifyItemResolver == nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("invest_item_amplify_option blocked: runtime-PVF resolver unavailable: %v", ErrAmplifyItemResolverRequired),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(req.Opcode, buildInvestItemAmplifyOptionErrorAck(investAmplifyErrorInvalid)),
|
||
},
|
||
}, nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
result, err := owner.InvestAmplifyOption(ctx, cmd, req.AmplifyItemResolver)
|
||
if err != nil {
|
||
return ownerBlockedResult(cmd, err), nil
|
||
}
|
||
return ownerAppliedInvestAmplifyOptionResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketUnsealRandomOption:
|
||
parsed, err := DecodeUnsealRandomOptionRequest(req.Body)
|
||
cmd := NewUnsealRandomOptionCommand(req, parsed)
|
||
if err != nil || req.RandomOptionResolver == nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, fmt.Errorf("parse=%v resolver_available=%t", err, req.RandomOptionResolver != nil)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result, err := owner.UnsealRandomOption(ctx, cmd, req.RandomOptionResolver)
|
||
if err != nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedRandomOptionResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketRegenerationRandomOption:
|
||
parsed, err := DecodeEquipmentRegenerationRequest(req.Body)
|
||
if err != nil || req.EquipmentRegenerationResolver == nil {
|
||
return equipmentRegenerationFailureResult(req.Opcode, fmt.Errorf("parse=%v resolver_available=%t", err, req.EquipmentRegenerationResolver != nil)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return equipmentRegenerationFailureResult(req.Opcode, err), nil
|
||
}
|
||
result, err := owner.RegenerateEquipment(ctx, req.AccountID, req.SelectedCharacterID, parsed, req.EquipmentRegenerationResolver)
|
||
if err != nil || !result.Success {
|
||
if err == nil {
|
||
err = fmt.Errorf("equipment regeneration rejected")
|
||
}
|
||
return equipmentRegenerationFailureResult(req.Opcode, err), nil
|
||
}
|
||
return equipmentRegenerationAppliedResult(req.Opcode, result), nil
|
||
case dnfenum.CmdPacketChangeRandomOption:
|
||
parsed, err := DecodeChangeRandomOptionRequest(req.Body)
|
||
cmd := NewChangeRandomOptionCommand(req, parsed)
|
||
if err != nil || req.RandomOptionResolver == nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, fmt.Errorf("parse=%v resolver_available=%t", err, req.RandomOptionResolver != nil)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, err), nil
|
||
}
|
||
result, err := owner.ChangeRandomOption(ctx, cmd, req.RandomOptionResolver)
|
||
if err != nil {
|
||
return randomOptionFailureResult(req.Opcode, cmd, err), nil
|
||
}
|
||
return ownerAppliedRandomOptionResult(req.Opcode, cmd, result), nil
|
||
case dnfenum.CmdPacketResetRandomOption:
|
||
parsed, err := DecodeResetRandomOptionRequest(req.Body)
|
||
cmd := NewResetRandomOptionCommand(req, parsed)
|
||
if err != nil || req.RandomOptionResolver == nil {
|
||
return randomOptionResetFailureResult(req.Opcode, cmd, randomOptionResetErrorInvalidMaterial,
|
||
fmt.Errorf("parse=%v resolver_available=%t", err, req.RandomOptionResolver != nil)), nil
|
||
}
|
||
owner, err := NewOwner(req.Repositories)
|
||
if err != nil {
|
||
return randomOptionResetFailureResult(req.Opcode, cmd, randomOptionResetErrorInvalidMaterial, err), nil
|
||
}
|
||
result, err := owner.ResetRandomOption(ctx, cmd, req.RandomOptionResolver)
|
||
if err != nil {
|
||
return randomOptionResetFailureResult(req.Opcode, cmd, randomOptionResetErrorInvalidMaterial, err), nil
|
||
}
|
||
return ownerAppliedRandomOptionResetResult(req.Opcode, cmd, result), nil
|
||
default:
|
||
return alignedcmd.Result{
|
||
Handled: false,
|
||
ResponseAllowed: false,
|
||
Operation: "unregistered",
|
||
Reason: fmt.Sprintf("inventory 模块未登记 opcode %d", req.Opcode),
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
func forgingRejectedResult(opcode uint16, cmd Command, reason string) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("weapon forging rejected: %s reason=%s", cmd.String(), reason),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildForgingErrorAck(4)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func chronicleRefineRejectedResult(opcode uint16, cmd Command, errorCode byte, reason string) alignedcmd.Result {
|
||
if errorCode == 0 {
|
||
errorCode = chronicleRefineErrorInvalidMaterial
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("chronicle refine rejected: %s reason=%s", cmd.String(), reason),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildChronicleRefineErrorAck(errorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedChronicleRefineResult(opcode uint16, cmd Command, result ChronicleRefineResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return chronicleRefineRejectedResult(opcode, cmd, result.ErrorCode, "PVF or repository validation failed")
|
||
}
|
||
responses := []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildChronicleRefineResultAck(result)),
|
||
}
|
||
mainRows := map[int16]dnfrepo.ItemStack{
|
||
result.MaterialSlotIndex: result.MaterialUpdatedStack,
|
||
}
|
||
for _, reward := range result.FailureRewards {
|
||
mainRows[reward.SlotIndex] = reward.UpdatedStack
|
||
}
|
||
if result.TargetListType == listTypeMain {
|
||
mainRows[result.TargetSlotIndex] = result.TargetUpdatedStack
|
||
} else {
|
||
responses = append(responses, class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeEquipment, []commonItemListEntry{{slot: result.TargetSlotIndex, stack: result.TargetUpdatedStack}})))
|
||
}
|
||
entries := make([]commonItemListEntry, 0, len(mainRows))
|
||
for slot, stack := range mainRows {
|
||
entries = append(entries, commonItemListEntry{slot: slot, stack: stack})
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot })
|
||
responses = append(responses, class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)))
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"chronicle refine applied: %s aura_type=%d canonical_id=%d option_count=%d probability=%d roll=%d refine_succeeded=%t target_destroyed=%t rewards=%d material_left=%d; sent current-EXE op370 result then op14 row updates",
|
||
cmd.String(), result.AuraType, result.CanonicalAuraOptionID, result.OptionCount, result.Probability, result.ProbabilityRoll,
|
||
result.RefineSucceeded, result.TargetDestroyed, len(result.FailureRewards), result.MaterialRemainingCount,
|
||
),
|
||
UpperResponses: responses,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedForgingResult(opcode uint16, cmd Command, result ForgingResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return forgingRejectedResult(opcode, cmd, "PVF or repository validation failed")
|
||
}
|
||
responses := []alignedcmd.UpperResponse{
|
||
// Current NoPack registers command-response op438 to the forging window
|
||
// callback. The leading success marker is consumed by the common wrapper;
|
||
// the remaining 12-byte result updates both item views and posts the native
|
||
// success/failure dialog before the authoritative op14 refresh.
|
||
class1Response(opcode, buildForgingResultAck(result)),
|
||
}
|
||
if result.TargetListType == listTypeMain {
|
||
entries := []commonItemListEntry{
|
||
{slot: result.TargetSlotIndex, stack: result.TargetUpdatedStack},
|
||
{slot: result.MaterialSlotIndex, stack: result.MaterialUpdatedStack},
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot })
|
||
responses = append(responses, class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)))
|
||
} else {
|
||
responses = append(responses,
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeEquipment, []commonItemListEntry{{slot: result.TargetSlotIndex, stack: result.TargetUpdatedStack}})),
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.MaterialSlotIndex, stack: result.MaterialUpdatedStack}})),
|
||
)
|
||
}
|
||
var areaBroadcasts []alignedcmd.UpperResponse
|
||
if forgingBroadcastRequired(result.NewLevel) {
|
||
notice := class0Response(msgUpgradeNotice, buildForgingNoticeBody(
|
||
result.UpgradeSucceeded,
|
||
cmd.SelectedCharacterID,
|
||
result.TargetItemTemplateID,
|
||
result.ReinforceLevel,
|
||
result.NewLevel,
|
||
))
|
||
responses = append(responses, notice)
|
||
areaBroadcasts = append(areaBroadcasts, notice)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"weapon forging applied: %s level=%d->%d succeeded=%t independentAttack=%d successWeight=%d material=(%d,%d,left=%d) changed=%t; sent current-EXE op438 native 13-byte success result then op14 row updates broadcast=%t",
|
||
cmd.String(), result.OldLevel, result.NewLevel, result.UpgradeSucceeded, result.IndependentAttack, result.SuccessWeight,
|
||
result.MaterialItemID, result.MaterialCost, result.MaterialRemainingCount, result.Changed, forgingBroadcastRequired(result.NewLevel),
|
||
),
|
||
UpperResponses: responses,
|
||
AreaBroadcastResponses: areaBroadcasts,
|
||
}
|
||
}
|
||
|
||
func forgingBroadcastRequired(level byte) bool {
|
||
return level >= 6
|
||
}
|
||
|
||
func randomOptionFailureResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("random-option request rejected: %s error=%v", cmd.String(), err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildRandomOptionStatusAck(false)),
|
||
},
|
||
}
|
||
}
|
||
|
||
// randomOptionResetFailureResult always answers op455. The current 90 client
|
||
// blocks the whole inventory UI until the pending command answers, so every
|
||
// rejection path must still emit the class1 result body: {u8 0} followed by the
|
||
// error code the client's op455 handler paints.
|
||
func randomOptionResetFailureResult(opcode uint16, cmd Command, errorCode byte, err error) alignedcmd.Result {
|
||
if errorCode == 0 {
|
||
errorCode = randomOptionResetErrorInvalidMaterial
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"random-option seal-grade reset rejected: %s target_slot=%d material_slot=%d option_index=%d error_code=%d error=%v",
|
||
cmd.String(), cmd.TargetSlotIndex, cmd.MaterialSlotIndex, cmd.RandomOptionIndex, errorCode, err,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildRandomOptionResetErrorAck(errorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ownerAppliedRandomOptionResetResult answers a committed op455 with the
|
||
// incremental item rows for the re-graded equipment and the consumed adjustment
|
||
// box, then the class1 success byte.
|
||
func ownerAppliedRandomOptionResetResult(opcode uint16, cmd Command, result RandomOptionResetResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
errorCode := result.ErrorCode
|
||
if errorCode == 0 {
|
||
errorCode = randomOptionResetErrorInvalidMaterial
|
||
}
|
||
failure := randomOptionResetFailureResult(opcode, cmd, errorCode, fmt.Errorf("failure=%s item=%d", result.Failure, result.TargetItemID))
|
||
failure.Reason = fmt.Sprintf(
|
||
"random-option seal-grade reset rejected: %s item=%d material=%d option_index=%d failure=%s error_code=%d",
|
||
cmd.String(), result.TargetItemID, result.MaterialItemID, result.OptionIndex, result.Failure, errorCode,
|
||
)
|
||
return failure
|
||
}
|
||
entries := make([]commonItemListEntry, 0, 2)
|
||
entries = append(entries, commonItemListEntry{slot: result.TargetSlotIndex, stack: result.UpdatedTarget})
|
||
entries = append(entries, commonItemListEntry{slot: result.MaterialSlotIndex, stack: result.UpdatedMaterial})
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"random-option seal-grade reset applied: %s item=%d option_index=%d type=%d grade=%d percent=%d->%d material=%d consumed=%t",
|
||
cmd.String(), result.TargetItemID, result.OptionIndex, result.OptionType,
|
||
result.Grade, result.OldPercent, result.NewPercent, result.MaterialItemID, result.MaterialConsumed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)),
|
||
class1Response(opcode, buildRandomOptionStatusAck(true)),
|
||
},
|
||
// 客户端 op455 处理器只处理失败分支,成功提示由服务端补发(与 op81
|
||
// 品级调整同款系统提示),详见 alignedcmd.PostActionNotifyRandomOptionResetSuccess。
|
||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionNotifyRandomOptionResetSuccess},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedRandomOptionResult(opcode uint16, cmd Command, result RandomOptionMutationResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
if result.Failure == RandomOptionFailureInsufficientGold {
|
||
failure := randomOptionFailureResult(opcode, cmd, fmt.Errorf(
|
||
"insufficient gold required=%d available=%d",
|
||
result.GoldCost,
|
||
result.UpdatedGold,
|
||
))
|
||
failure.PostActions = []alignedcmd.PostAction{alignedcmd.PostActionNotifyRandomOptionGoldInsufficient}
|
||
return failure
|
||
}
|
||
return randomOptionFailureResult(opcode, cmd, fmt.Errorf("owner rejected target item=%d", result.TargetItemID))
|
||
}
|
||
// 【扣了金币就必须把余额推给客户端】
|
||
//
|
||
// 修理 / 出售把新余额塞在自己的 ACK 里(buildRepairEquipmentAck /
|
||
// buildSellItemAck 的 updatedGold 字段),客户端因此实时更新。随机属性家族
|
||
// (op401 解封 / op437 变更)的成功 ACK 只有一个 result 字节
|
||
// (buildRandomOptionStatusAck -> {0x01}),没有任何放金币的位置,于是服务端
|
||
// 扣了钱而客户端仍显示旧余额 —— 玩家要小退重登才能看到扣完的数字。
|
||
//
|
||
// 主列表 0 号槽就是金币钱包行(id=0,余额放在 count 字段,见
|
||
// currentGoldWalletItemListEntry / writeCommonItemListEntry 的 goldWallet 分支),
|
||
// 所以这里挂 PostActionRefreshSelectedMainWallets,由 bridge 在道具行和 ACK 之后
|
||
// 重发一次钱包刷新(它会重新读盘后的余额,读到的正是刚提交的事务结果)。
|
||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||
if result.GoldCost > 0 {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedMainWallets)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("random-option owner applied: %s item=%d options=%d gold_cost=%d updated_gold=%d percentile_fallback=%t", cmd.String(), result.TargetItemID, len(result.Options), result.GoldCost, result.UpdatedGold, result.PercentileFallback),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.TargetSlotIndex, stack: result.UpdatedStack}})),
|
||
class1Response(opcode, buildRandomOptionStatusAck(true)),
|
||
},
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
func equipmentRegenerationFailureResult(opcode uint16, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Decision: alignedcmd.Decision{Action: alignedcmd.ActionPendingModule, Domain: dnfenum.AlignedDomainInventory, Support: dnfenum.AlignedSupportDirect},
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: "equipment_regeneration",
|
||
Reason: err.Error(),
|
||
UpperResponses: []alignedcmd.UpperResponse{class1Response(opcode, []byte{0, equipmentRegenerationErrorDefault})},
|
||
}
|
||
}
|
||
|
||
func equipmentRegenerationAppliedResult(opcode uint16, result EquipmentRegenerationResult) alignedcmd.Result {
|
||
entries := make([]commonItemListEntry, 0, len(result.ChangedRows))
|
||
for _, row := range result.ChangedRows {
|
||
entries = append(entries, commonItemListEntry{slot: row.SlotIndex, stack: row.Stack})
|
||
}
|
||
return alignedcmd.Result{
|
||
Decision: alignedcmd.Decision{Action: alignedcmd.ActionPendingModule, Domain: dnfenum.AlignedDomainInventory, Support: dnfenum.AlignedSupportDirect},
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: "equipment_regeneration",
|
||
Reason: fmt.Sprintf("op407 committed source=%d result=%d slot=%d candidates=%d", result.SourceItemID, result.ResultItemID, result.SourceSlotIndex, result.CandidateCount),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.SourceSlotIndex}})),
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)),
|
||
class1Response(opcode, []byte{1, byte(result.SourceSlotIndex), byte(result.SourceSlotIndex >> 8)}),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedPurifyItemResult(opcode uint16, cmd Command, result AmplifyMutationResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("inventory owner rejected: %s internal_error=%d; returned current-EXE op204 fixed failure ACK", cmd.String(), result.ErrorCode),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildPurifyItemErrorAck()),
|
||
},
|
||
}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s mode=%s amplify=(%d,%d) materialLeft=%d; returned self-contained current-EXE op204 ACK without unproven item-list replay",
|
||
cmd.String(), result.Mode, result.AmplifyType, result.AmplifyValue, result.MaterialRemainingCount,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildPurifyItemSuccessAck(result)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedInvestAmplifyOptionResult(opcode uint16, cmd Command, result AmplifyMutationResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("inventory owner rejected: %s error=%d; returned current-EXE op205 error ACK", cmd.String(), result.ErrorCode),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildInvestItemAmplifyOptionErrorAck(result.ErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s mode=%s amplify=(%d,%d) level=%d materialLeft=%d; returned self-contained current-EXE op205 ACK without item-list replay",
|
||
cmd.String(), result.Mode, result.AmplifyType, result.AmplifyValue, result.AmplifyLevel, result.MaterialRemainingCount,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildInvestItemAmplifyOptionSuccessAck(result)),
|
||
},
|
||
}
|
||
}
|
||
|
||
// upgradeTargetStackOfSlot reads the target the upgrade command names, from the
|
||
// backpack (list 0) or the equipment record (list 3), so the PVF policy
|
||
// resolver can pick the correct table row before the atomic transaction begins.
|
||
// Both the current level and the 红字 attribute come off this one load: the
|
||
// destruction refund needs the latter, because the disjointer's otherverse
|
||
// bonus is keyed off it.
|
||
//
|
||
// The backpack lookup is keyed by the command's **own** target list, not by
|
||
// list 0: the list-38 公会徽章 page stores its rows in the same backpack map
|
||
// under `38:<slot>` (upstream's upgradeLevelOfSlot does exactly this). Reading
|
||
// `0:<slot>` for a medal handed the policy resolver the level of whatever
|
||
// unrelated stack shares that index — and the owner's list-38 gate
|
||
// (`int(oldLevel) != cmd.UpgradePolicyLevel`) then rejected the attempt with
|
||
// error 4, i.e. the same "勋章不给强化" symptom from a second direction.
|
||
func upgradeTargetStackOfSlot(repos dnfrepo.Group, ctx context.Context, cmd Command) (dnfrepo.ItemStack, bool) {
|
||
if cmd.SelectedCharacterID == 0 {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
if cmd.TargetListType == listTypeEquipment {
|
||
if repos.Equipment == nil {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
record, found, err := repos.Equipment.Load(ctx, characterID)
|
||
if err != nil || !found || len(record.Entries) == 0 {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
entry, ok := record.Entries[strconv.Itoa(int(cmd.TargetSlotIndex))]
|
||
if !ok || entry.ItemID != int64(cmd.TargetItemTemplateID) {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
return forgingStackFromEquipmentEntry(entry), true
|
||
}
|
||
if repos.Inventory == nil {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
record, found, err := repos.Inventory.Load(ctx, characterID)
|
||
if err != nil || !found || record.Slots == nil {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
stack, ok := record.Slots[slotKey(cmd.TargetListType, cmd.TargetSlotIndex)]
|
||
if !ok || stack.ItemID != int64(cmd.TargetItemTemplateID) {
|
||
return dnfrepo.ItemStack{}, false
|
||
}
|
||
return stack, true
|
||
}
|
||
|
||
func upgradeMaterialItemIDOfSlot(repos dnfrepo.Group, ctx context.Context, cmd Command) int64 {
|
||
if repos.Inventory == nil || cmd.SelectedCharacterID == 0 || cmd.MaterialSlotIndex < 0 {
|
||
return 0
|
||
}
|
||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||
record, found, err := repos.Inventory.Load(ctx, characterID)
|
||
if err != nil || !found || record.Slots == nil {
|
||
return 0
|
||
}
|
||
stack, found := record.Slots[slotKey(listTypeMain, cmd.MaterialSlotIndex)]
|
||
if !found || stack.Count <= 0 {
|
||
return 0
|
||
}
|
||
return stack.ItemID
|
||
}
|
||
|
||
// highUpgradeFuelItemID is 高级炉岩炭 (stackable/cash/chn_high_upgrade), the
|
||
// fuel the 高级装备强化机 burns in place of 无色小晶块.
|
||
const highUpgradeFuelItemID = 2681644
|
||
|
||
// highUpgradeSuccessNumerator/Denominator scale the table's success weight for
|
||
// the advanced machine, and highUpgradeFuelDivisor is how much less fuel it
|
||
// burns than the ordinary machine's 无色小晶块. The item's own [explain] reads
|
||
// 「使用高级炉岩炭时的强化成功率为普通炉岩炭的110%」; etc/upgrade.etc has no
|
||
// high-upgrade table at all, so both the multiplier and the 1/10 fuel ratio
|
||
// are the operator's terms, not PVF data.
|
||
const (
|
||
highUpgradeSuccessNumerator = 11
|
||
highUpgradeSuccessDenominator = 10
|
||
highUpgradeFuelDivisor = 10
|
||
)
|
||
|
||
// applyUpgradeHighUpgradeTerms rewrites the resolved policy for op50 sub mode
|
||
// 2: the material requirement becomes 高级炉岩炭 at a tenth of the table's
|
||
// 无色小晶块 count, and the success weight is multiplied by 1.1 and capped at
|
||
// the guaranteed-success base. Everything else -- gold, penalty type,
|
||
// destruction compensation -- stays exactly as the table says.
|
||
//
|
||
// The fuel count rounds up and never reaches zero: a row asking for fewer than
|
||
// ten crystals must still cost one fuel, not become free.
|
||
func applyUpgradeHighUpgradeTerms(cmd *Command) {
|
||
if cmd == nil || !cmd.UpgradeHighUpgrade {
|
||
return
|
||
}
|
||
if cmd.UpgradeMaterialCount > 0 {
|
||
cmd.UpgradeMaterialItemID = highUpgradeFuelItemID
|
||
cmd.UpgradeMaterialCount = (cmd.UpgradeMaterialCount + highUpgradeFuelDivisor - 1) / highUpgradeFuelDivisor
|
||
}
|
||
if cmd.UpgradeSuccessWeight > 0 {
|
||
scaled := cmd.UpgradeSuccessWeight * highUpgradeSuccessNumerator / highUpgradeSuccessDenominator
|
||
if scaled > currentUpgradeSuccessBase {
|
||
scaled = currentUpgradeSuccessBase
|
||
}
|
||
cmd.UpgradeSuccessWeight = scaled
|
||
}
|
||
}
|
||
|
||
// upgradeTicketTargetListSupported 界定 op50「券场景」能服务的目标列表。
|
||
//
|
||
// 上游的两处券前置(便携式强化器与 direct-level 券)都写死
|
||
// `cmd.TargetListType == listTypeMain`;本树另外支持**穿戴 list-3** 的券,
|
||
// 所以放行 0/3。**list-38 公会徽章必须排除**:
|
||
//
|
||
// - 勋章页面压根没有券场景:它的材料是 flag_upgrade_table 行自带的
|
||
// 公会贡献证书,不是强化券;
|
||
// - 而 UpgradeTicket 的目标分支只认 list 0 与 list 3(装备记录),list 38 落到
|
||
// default 分支 —— 那里带着 base 的 TicketResolved=true 直接返回,于是券前置
|
||
// 把这次请求当成「券已裁决」,回一个 error 4(无效目标)的 0x0050 ACK,
|
||
// 本树移植的勋章 owner 路径(owner.go 的 list-38 分支)**一次都跑不到**。
|
||
//
|
||
// 现场:2026-10-04 19:16:30 / 19:17:16 两次 op50 0x2600(完美的守护图腾勋章 100380058
|
||
// 在 38:2、古老的勋章 100380021 在 38:1,材料 公会贡献证书 10154439 在 0:179)都是
|
||
// `inventory owner rejected: ... goldPVF=0 ... error=4; returned 0x0050 error ACK`,
|
||
// 客户端表现为「勋章不给强化」。
|
||
func upgradeTicketTargetListSupported(cmd Command) bool {
|
||
return cmd.TargetListType == listTypeMain || cmd.TargetListType == listTypeEquipment
|
||
}
|
||
|
||
func upgradeMaterialSlotIsAccountShared(cmd Command) bool {
|
||
if cmd.MaterialSlotIndex < 0 {
|
||
return false
|
||
}
|
||
return dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.MaterialSlotIndex)
|
||
}
|
||
|
||
func ownerAppliedUpgradeResult(ctx context.Context, settings dnfrepo.SettingsRepository, opcode uint16, cmd Command, result UpgradeResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner rejected: %s error=%d; returned current-EXE op50 error ACK",
|
||
cmd.String(),
|
||
result.ErrorCode,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUpgradeItemErrorAck(result.ErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
responses := []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUpgradeItemSuccessAck(result)),
|
||
}
|
||
// A worn target is not part of the list-0 refresh: its row is repainted by
|
||
// the bridge through one single-slot list-3 op14 (EquipmentSlotRefreshes).
|
||
// Never widen that to the whole worn table - a bulk list-3 op14 terminates
|
||
// the current client while it owns live equipment objects.
|
||
equippedTarget := result.TargetListType == listTypeEquipment
|
||
if entries := upgradeItemUpdateEntries(result, equippedTarget); len(entries) > 0 {
|
||
responses = append(responses, class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)))
|
||
}
|
||
var equipmentRefreshes []int16
|
||
if equippedTarget && result.ResultCode != upgradeResultCodeDestroy {
|
||
equipmentRefreshes = []int16{result.TargetSlotIndex}
|
||
}
|
||
// NOTI 0x0056 upgrade announcement when level meets notice threshold.
|
||
if cmd.UpgradeNoticeLevel >= 0 {
|
||
noticeLevel := result.NewLevel
|
||
if !result.UpgradeSucceeded {
|
||
noticeLevel = result.OldLevel
|
||
}
|
||
if int(noticeLevel) >= cmd.UpgradeNoticeLevel {
|
||
responses = append(responses, class0Response(msgUpgradeNotice, buildUpgradeNoticeBody(
|
||
result.UpgradeSucceeded, cmd.SelectedCharacterID, result.TargetItemTemplateID, noticeLevel)))
|
||
}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s mode=%s level=%d->%d materialLeft=%d protectionUsed=%t protectionLeft=%d protectionItem=%d goldCost=%d changed=%t resultCode=%d; opened 0x0050 ACK + %s in 86JP order",
|
||
cmd.String(),
|
||
result.Mode,
|
||
result.OldLevel,
|
||
result.NewLevel,
|
||
result.MaterialRemainingStackCount,
|
||
result.ProtectionTicketUsed,
|
||
result.ProtectionTicketRemaining,
|
||
result.ProtectionTicketItemID,
|
||
result.GoldCost,
|
||
result.Changed,
|
||
result.ResultCode,
|
||
upgradeRefreshLabel(equippedTarget),
|
||
),
|
||
UpperResponses: responses,
|
||
EquipmentSlotRefreshes: equipmentRefreshes,
|
||
}
|
||
}
|
||
|
||
// upgradeRefreshLabel names the refresh the handler opens after the op50 ACK.
|
||
func upgradeRefreshLabel(equippedTarget bool) string {
|
||
if equippedTarget {
|
||
return "0x000D material-only refresh + single-slot list3 equipment refresh"
|
||
}
|
||
return "0x000D main-list refresh"
|
||
}
|
||
|
||
// upgradeItemUpdateEntries builds the list-0 row refresh. For a worn target
|
||
// the target row is excluded (it is repainted through EquipmentSlotRefreshes);
|
||
// only material, protection-ticket and destroy-bonus rows remain.
|
||
func upgradeItemUpdateEntries(result UpgradeResult, equippedTarget bool) []commonItemListEntry {
|
||
updates := make(map[int16]dnfrepo.ItemStack, 4)
|
||
// The wallet is main-list slot 0 (item id 0, balance in the count field),
|
||
// and it is not one of the mutated item slots, so nothing else here would
|
||
// carry it. Without this row the gold left the database but the client
|
||
// went on painting the old balance until the next full list refresh, which
|
||
// looks exactly like "强化没有扣金币". The NPC shop already refreshes the
|
||
// wallet this way.
|
||
if result.GoldCost > 0 {
|
||
updates[0] = dnfrepo.ItemStack{Count: result.UpdatedGold}
|
||
}
|
||
if !equippedTarget && result.TargetSlotIndex >= 0 {
|
||
updates[result.TargetSlotIndex] = result.TargetUpdatedStack
|
||
}
|
||
if result.MaterialUpdated && result.MaterialSlotIndex >= 0 {
|
||
updates[result.MaterialSlotIndex] = result.MaterialUpdatedStack
|
||
}
|
||
if result.ProtectionTicketUsed {
|
||
protectionSlot := result.ProtectionTicketSlotIndex
|
||
// Slot 0 is not a legal protection-ticket endpoint. Treat the zero
|
||
// value as unspecified so older/directly constructed results continue
|
||
// to refresh the explicit request slot.
|
||
if protectionSlot <= 0 {
|
||
protectionSlot = result.OptionalTicketSlotIndex
|
||
}
|
||
if protectionSlot >= 0 {
|
||
updates[protectionSlot] = result.ProtectionTicketUpdatedStack
|
||
}
|
||
}
|
||
for _, destroySlot := range result.DestroyBonusSlots {
|
||
if stack, ok := result.MainRefresh[slotKey(listTypeMain, destroySlot)]; ok {
|
||
updates[destroySlot] = stack
|
||
}
|
||
}
|
||
if len(result.DestroyBonusSlots) == 0 && result.DestroyBonusItemID > 0 && result.DestroyBonusSlot >= 0 {
|
||
if stack, ok := result.MainRefresh[slotKey(listTypeMain, result.DestroyBonusSlot)]; ok {
|
||
updates[result.DestroyBonusSlot] = stack
|
||
}
|
||
}
|
||
entries := make([]commonItemListEntry, 0, len(updates))
|
||
for slot, stack := range updates {
|
||
entries = append(entries, commonItemListEntry{slot: slot, stack: stack})
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot })
|
||
return entries
|
||
}
|
||
|
||
func ownerAppliedEnchantResult(ctx context.Context, settings dnfrepo.SettingsRepository, opcode uint16, cmd Command, result EnchantResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner rejected: %s error=%d; returned current-EXE 0x0110 error ACK",
|
||
cmd.String(),
|
||
result.ErrorCode,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildEnchantByBeadErrorAck(result.ErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, settings, result.CharacterID)
|
||
if err != nil || !found {
|
||
state = dnfrepo.CharacterContainerState{}
|
||
}
|
||
if result.TargetListType == listTypePet {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied pet enchant: %s card=%d upgrade=%d beadLeft=%d changed=%t; sent target list-7 and bead list-0 op14 rows before current-EXE 0x0110 ACK",
|
||
cmd.String(),
|
||
result.CardItemID,
|
||
result.EnchantUpgradeCount,
|
||
result.BeadRemainingCount,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListUpdate, buildPetItemListUpdateBody([]commonItemListEntry{{slot: result.TargetSlotIndex, stack: result.TargetUpdatedStack}})),
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.BeadSlotIndex, stack: result.BeadUpdatedStack}})),
|
||
class1Response(opcode, buildEnchantByBeadSuccessAck(result)),
|
||
},
|
||
}
|
||
}
|
||
if result.TargetListType == listTypeEquipment && result.TargetSlotIndex == 26 {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied equipped creature enchant: %s card=%d upgrade=%d beadLeft=%d changed=%t; sent bead row then current-EXE 0x0110 ACK and deferred worn creature op14 refresh",
|
||
cmd.String(), result.CardItemID, result.EnchantUpgradeCount,
|
||
result.BeadRemainingCount, result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.BeadSlotIndex, stack: result.BeadUpdatedStack}})),
|
||
class1Response(opcode, buildEnchantByBeadSuccessAck(result)),
|
||
},
|
||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedEquippedCreatureItem},
|
||
}
|
||
}
|
||
if result.TargetListType == listTypeEquipment {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied worn equipment enchant: %s card=%d upgrade=%d beadLeft=%d changed=%t; sent target list-3 and bead list-0 op14 rows before current-EXE 0x0110 ACK so the ACK resolves the refreshed worn item",
|
||
cmd.String(), result.CardItemID, result.EnchantUpgradeCount,
|
||
result.BeadRemainingCount, result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
// 附魔身上穿着的装备时,被附魔的那一行必须在 ACK 之前发:ACK 的
|
||
// handler 会把目标槽解析成物品指针并交给结果弹窗,行先到则弹窗拿到
|
||
// 的是已带新附魔属性的对象。活体 2026-09-15:只发魔石行时,客户端
|
||
// 直到小退重建装备才显示附魔属性。
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeEquipment, []commonItemListEntry{{slot: result.TargetSlotIndex, stack: result.TargetUpdatedStack}})),
|
||
class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, []commonItemListEntry{{slot: result.BeadSlotIndex, stack: result.BeadUpdatedStack}})),
|
||
class1Response(opcode, buildEnchantByBeadSuccessAck(result)),
|
||
},
|
||
}
|
||
}
|
||
// The current EXE's 0x0110 success handler resolves the target slot to an
|
||
// item pointer and publishes that pointer to the result popup. A generic
|
||
// selected-container refresh after the ACK destroys and recreates the
|
||
// item objects while the popup still owns the pointer, so the popup can
|
||
// render an unrelated item that later reuses the same allocation.
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s card=%d upgrade=%d beadLeft=%d changed=%t; opened one 0x000D main-list refresh then 0x0110 ACK without a pointer-invalidating duplicate refresh",
|
||
cmd.String(),
|
||
result.CardItemID,
|
||
result.EnchantUpgradeCount,
|
||
result.BeadRemainingCount,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class0Response(msgItemListRefresh, buildCommonItemListRefreshBodyWithState(listTypeMain, result.MainRefresh, state)),
|
||
class1Response(opcode, buildEnchantByBeadSuccessAck(result)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func rejectedMonsterCardUpgradeResult(opcode uint16, cmd Command, errorCode byte, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("monster card upgrade rejected: %s error=%d: %v", cmd.String(), errorCode, err),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMonsterCardUpgradeErrorAck(errorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedMonsterCardUpgradeResult(opcode uint16, cmd Command, result MonsterCardUpgradeResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return rejectedMonsterCardUpgradeResult(
|
||
opcode,
|
||
cmd,
|
||
result.ErrorCode,
|
||
fmt.Errorf("repository state did not satisfy the op603 card-upgrade contract"),
|
||
)
|
||
}
|
||
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.ChangedSlots))
|
||
for _, slot := range result.ChangedSlots {
|
||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: slot})
|
||
}
|
||
// 升级成功后补一次 list-0 整表重建。
|
||
//
|
||
// 只发增量行是不够的:卡片升到**最后一级**时结果留在原槽(例如 242 -> 242),
|
||
// 行里只有升级字节 03 -> 04 变了,item id 和槽位都没动 —— 这个旧客户端对这种
|
||
// "原地改行"不重绘,背包会一直显示上一级(玩家实测满级后仍显示 3/4,重新选
|
||
// 角色才正常)。前几级之所以没事,是因为每次都换了槽位,客户端看到的是
|
||
// "删一个 + 加一个"。
|
||
//
|
||
// 只在**升级成功**时重建:失败那次卡片和材料都没动,增量行足够。
|
||
var rebuilds []byte
|
||
if result.UpgradeSucceeded {
|
||
rebuilds = []byte{listTypeMain}
|
||
}
|
||
goldAfter := result.UpdatedGold
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"monster card upgrade committed: %s item=%d roll_success=%t upgrade=%d chance=%d/%d gold_cost=%d result_slot=%d changed_slots=%v",
|
||
cmd.String(), result.TargetItemID, result.UpgradeSucceeded, result.UpgradeCount,
|
||
result.Chance, monsterCardUpgradeProbabilityBase, result.GoldCost, result.ResultSlot, result.ChangedSlots,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMonsterCardUpgradeSuccessAck(result)),
|
||
},
|
||
ItemSlotRefreshes: refreshes,
|
||
ItemListRebuilds: rebuilds,
|
||
CharacterGoldAfter: &goldAfter,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedUpgradeTicketResult(ctx context.Context, settings dnfrepo.SettingsRepository, opcode uint16, cmd Command, result UpgradeTicketResult) alignedcmd.Result {
|
||
if !result.Success {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner rejected: %s error=%d; returned 0x0050 error ACK",
|
||
cmd.String(),
|
||
result.ErrorCode,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUpgradeTicketErrorAck(result.ErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
responses := []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUpgradeTicketSuccessAck(result)),
|
||
}
|
||
equippedTarget := result.TargetListType == listTypeEquipment
|
||
if entries := upgradeTicketUpdateEntries(result, equippedTarget); len(entries) > 0 {
|
||
responses = append(responses, class0Response(msgItemListUpdate, buildCommonItemListUpdateBody(listTypeMain, entries)))
|
||
}
|
||
var equipmentRefreshes []int16
|
||
if equippedTarget {
|
||
equipmentRefreshes = []int16{result.TargetSlotIndex}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s mode=%s level=%d->%d roll=%t ticketLeft=%d changed=%t; opened 0x0050 ACK + %s in 86JP order",
|
||
cmd.String(),
|
||
result.Mode,
|
||
result.OldLevel,
|
||
result.NewLevel,
|
||
result.UpgradeSucceeded,
|
||
result.MaterialRemainingStackCount,
|
||
result.Changed,
|
||
upgradeRefreshLabel(equippedTarget),
|
||
),
|
||
UpperResponses: responses,
|
||
EquipmentSlotRefreshes: equipmentRefreshes,
|
||
}
|
||
}
|
||
|
||
// upgradeTicketUpdateEntries builds the list-0 row refresh. For a worn target
|
||
// the target row is excluded; only the ticket row changes in the backpack.
|
||
func upgradeTicketUpdateEntries(result UpgradeTicketResult, equippedTarget bool) []commonItemListEntry {
|
||
updates := make(map[int16]dnfrepo.ItemStack, 2)
|
||
if !equippedTarget && result.TargetSlotIndex >= 0 {
|
||
updates[result.TargetSlotIndex] = result.TargetUpdatedStack
|
||
}
|
||
if result.MaterialUpdated && result.MaterialSlotIndex >= 0 {
|
||
updates[result.MaterialSlotIndex] = result.MaterialUpdatedStack
|
||
}
|
||
entries := make([]commonItemListEntry, 0, len(updates))
|
||
for slot, stack := range updates {
|
||
entries = append(entries, commonItemListEntry{slot: slot, stack: stack})
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot })
|
||
return entries
|
||
}
|
||
|
||
func ownerAppliedMoveResult(ctx context.Context, settings dnfrepo.SettingsRepository, opcode uint16, cmd Command, result MoveResult) alignedcmd.Result {
|
||
moveValue := clampInt32(result.MoveCount)
|
||
if result.Mode == "noop" {
|
||
moveValue = 0
|
||
}
|
||
ackCommand := cmd
|
||
if result.RedirectedReservedMainSlot {
|
||
ackCommand.SourceListType = result.SourceListType
|
||
ackCommand.SourceSlotIndex = result.SourceSlotIndex
|
||
ackCommand.DestinationListType = result.DestinationListType
|
||
ackCommand.DestinationSlotIndex = result.DestinationSlotIndex
|
||
}
|
||
responses := []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceSuccessAck(ackCommand, moveValue)),
|
||
}
|
||
itemSlotRefreshes := make([]alignedcmd.ItemSlotRefresh, 0, 2)
|
||
// 2026-09-13 结论(客户端 sub_1D2DE80 + 86JP 参考一致):op19 的 ACK 本身就带
|
||
// 源/目标 list+槽与移动值,客户端据此自行重画,**不需要也不应收到额外的物品行**:
|
||
// 补发的精确槽位行会被客户端当成额外变化套到它自己的 op19 对象上,现场表现为
|
||
// "放进背包的那件叠加成了被换到仓库的那件"。因此这里保持只有跨容器"叠加"这一
|
||
// 种情形走精确槽位(历史行为),swap 回到只发 ACK。
|
||
crossContainerStack := result.Changed &&
|
||
result.Mode == "stack" &&
|
||
isSupportedCrossContainerStack(ackCommand.SourceListType, ackCommand.DestinationListType)
|
||
if crossContainerStack {
|
||
if ackCommand.SourceListType != listTypeAccountCargo {
|
||
itemSlotRefreshes = append(itemSlotRefreshes,
|
||
alignedcmd.ItemSlotRefresh{ListType: ackCommand.SourceListType, SlotIndex: ackCommand.SourceSlotIndex},
|
||
)
|
||
}
|
||
if ackCommand.DestinationListType != listTypeAccountCargo {
|
||
itemSlotRefreshes = append(itemSlotRefreshes,
|
||
alignedcmd.ItemSlotRefresh{ListType: ackCommand.DestinationListType, SlotIndex: ackCommand.DestinationSlotIndex},
|
||
)
|
||
}
|
||
} else {
|
||
for _, listType := range result.RefreshListTypes {
|
||
slots, ok := result.Refresh[listType]
|
||
if !ok {
|
||
continue
|
||
}
|
||
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, settings, result.CharacterID)
|
||
if err != nil || !found {
|
||
state = dnfrepo.CharacterContainerState{}
|
||
}
|
||
responses = append(responses, class0Response(
|
||
msgItemListRefresh,
|
||
buildCommonItemListRefreshBodyWithState(listType, slots, state),
|
||
))
|
||
}
|
||
}
|
||
// 账号共享晶体/灵魂槽(list 0, 354..365)搬空时必须补一格精确删除行:op19 的
|
||
// ACK 只带源/目标端点,客户端不据它重绘右侧晶体/灵魂面板,于是搬空的格子会留下
|
||
// "幽灵行"(现场 2026-09-17:无色小晶块从晶体仓库拖到个人仓库后,面板仍显示原来
|
||
// 的 40449,而且那一行点不动)。这里只补**空掉**的端点:补发物品行会被客户端当成
|
||
// 额外变化套到它自己的 op19 对象上(2026-09-13 的重复显示),删除行没有这个问题。
|
||
for _, slot := range result.EmptiedAccountSharedSlots {
|
||
itemSlotRefreshes = append(itemSlotRefreshes,
|
||
alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: slot},
|
||
)
|
||
}
|
||
// 落点被改写到账号共享固定格时,客户端声明的那一格也要显式清掉:op19 是乐观移动,
|
||
// 客户端发包前就已经把物品画在它自己声明的那一格上了(现场 2026-09-21:晶体从个人仓库
|
||
// 拖回背包时声明 0:156,服务端落到 0:357,客户端从此留下 0:156 的幽灵行,那一格点不动、
|
||
// 再往外拖连 op19 都不发)。owner 只在该格**持久化记录里确实为空**时才回报,所以这里补发
|
||
// 的必然是背包空格的删除行 (0xFFFFFFFF),不会盖掉真实物品。
|
||
if slot := result.ClearedRedirectedMainSlot; slot > 0 && slot != result.DestinationSlotIndex {
|
||
itemSlotRefreshes = append(itemSlotRefreshes,
|
||
alignedcmd.ItemSlotRefresh{ListType: listTypeMain, SlotIndex: slot},
|
||
)
|
||
}
|
||
// 86JP SendMoveSortLockSignals:ACK 之后按仍然锁定的端点槽位补发 class1/op713,
|
||
// 否则客户端的 padlock 会留在物品刚离开的旧槽位上。
|
||
for _, signal := range result.SortLockSignals {
|
||
responses = append(responses, class1Response(
|
||
uint16(dnfenum.CmdPacketRequestItemSortLock),
|
||
dnfitemlock.BuildSortLockStateBody(signal.ListType, signal.SlotIndex),
|
||
))
|
||
}
|
||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||
// 晶体/灵魂仓库(list 0, 354..365)参与的移动之后必须重绘那两排:op19 的 ACK 只声明
|
||
// 源/目标端点,客户端不会据它重绘右侧晶体/灵魂面板(2026-09-17 现场:搬走之后面板
|
||
// 还画着原来那一堆)。这一发按仓库当前占用行重放,同时带上金币钱包行。
|
||
accountSharedTouched := dnfrepo.IsAccountSharedInventorySlot(result.SourceListType, result.SourceSlotIndex) ||
|
||
dnfrepo.IsAccountSharedInventorySlot(result.DestinationListType, result.DestinationSlotIndex)
|
||
if accountSharedTouched ||
|
||
(result.Changed && isMainPersonalCargoCrossList(result.SourceListType, result.DestinationListType)) {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedMainWallets)
|
||
}
|
||
// 落点被改写到账号共享固定格的那一步,除增量行外还要补一次 list-0 **整表重建**。
|
||
//
|
||
// 为什么增量行治不了:搬空账号格时按晶体面板的语义发的是 (itemID=0, count=0) "空格"行,
|
||
// 而这个旧客户端的 list-0 行读取器把 **itemID=0 理解成金币对象**。玩家把晶体从晶体仓库
|
||
// 拖到个人仓库、再从个人仓库拖回背包(这一步落点被改写到固定格)之后,那一格在客户端
|
||
// 就成了"金币":右侧面板看着正常,但再往外拖时抓到的是金币(鼠标上的图标就是金币),
|
||
// 客户端自己的规则不允许拖金币,于是**连 op19 都不发**,那一格等于废了。
|
||
//
|
||
// 现场 2026-09-21(玩家 1111,蓝色小晶块 3036):晶体仓库 → 个人仓库 → 背包 走一遍之后
|
||
// 就再也拖不动;**小退(整份 list-0 快照重建)立刻恢复**,再走一遍又坏 —— 正是
|
||
// "整表重建能治、增量行治不了"的形态,与怪物卡片满级、宠物栏 list 7、list 38 同类。
|
||
//
|
||
// 只在"落点被改写"这一步重建(不是每次晶体移动):那一步才让客户端的乐观落点与服务端
|
||
// 的真实落点分叉,也正是幽灵/金币对象的现场。重建正文由
|
||
// mergeCurrentAccountSharedItemListEntries 把 354..365 一并铺回去,并且排在增量行之后
|
||
// 落地(见 game_aligned.go 的 ItemListRebuilds 循环),所以权威快照最后生效。
|
||
var rebuilds []byte
|
||
if result.Changed && accountSharedTouched && result.RedirectedReservedMainSlot {
|
||
rebuilds = []byte{listTypeMain}
|
||
}
|
||
// 搬空晶体/灵魂槽不再额外重放容器列表快照:清空那一格由 op14 的"空格"行
|
||
// (itemID=0, count=0) 负责,和拍卖行上架、摆摊售出同一条写法(现场 2026-09-17:
|
||
// 那两条路径能正确减少晶体仓库数量,拖拽路径当时发的是 0xFFFFFFFF 所以不行)。
|
||
if result.Changed && (result.SourceListType == listTypeAccountCargo || result.DestinationListType == listTypeAccountCargo) {
|
||
// List 12 is account-owned. The bridge rebuilds its current-EXE op13
|
||
// snapshot from account metadata + AccountInventory after the op19 ACK;
|
||
// it must not use the per-character common refresh path.
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedAccountCargo)
|
||
}
|
||
if result.Changed && (result.SourceListType == listTypePet || result.DestinationListType == listTypePet) {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedItemContainers)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s moveMode=%s moveCount=%d changed=%t refreshLists=%v incrementalSlots=%v; opened 0x0013 ACK then authoritative refresh for changed counts",
|
||
cmd.String(),
|
||
result.Mode,
|
||
result.MoveCount,
|
||
result.Changed,
|
||
result.RefreshListTypes,
|
||
itemSlotRefreshes,
|
||
),
|
||
UpperResponses: responses,
|
||
ItemSlotRefreshes: itemSlotRefreshes,
|
||
ItemListRebuilds: rebuilds,
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
func isSupportedCrossContainerStack(sourceListType, destinationListType byte) bool {
|
||
if sourceListType == destinationListType {
|
||
return false
|
||
}
|
||
supported := func(listType byte) bool {
|
||
switch listType {
|
||
case listTypeMain, listTypePersonalCargo, listTypeAccountCargo:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return supported(sourceListType) && supported(destinationListType)
|
||
}
|
||
|
||
func ownerAppliedSellResult(opcode uint16, cmd Command, result SellResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s sold=%d goldDelta=%d updatedGold=%d changed=%t; opened 0x0016 response in C# order",
|
||
cmd.String(),
|
||
result.Sold.AppliedCount,
|
||
result.GoldDelta,
|
||
result.UpdatedGold,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildSellItemAck(result.Sold, result.UpdatedGold)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedSortResult(opcode uint16, cmd Command, result SortResult) alignedcmd.Result {
|
||
postAction := alignedcmd.PostActionRefreshSelectedItemContainers
|
||
if result.ListType == listTypePersonalCargo {
|
||
postAction = alignedcmd.PostActionRefreshSelectedPersonalCargo
|
||
}
|
||
if result.ListType == listTypeAccountCargo {
|
||
// list 12 是账号所有者容器:它的 op13 快照由账号 metadata + AccountInventory
|
||
// 重建,不能用按角色的通用刷新体,否则金库表头和行会跟着当前角色变。
|
||
postAction = alignedcmd.PostActionRefreshSelectedAccountCargo
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s sortMode=%s moved=%d range=%d-%d changed=%t; opened 0x0014 ACK then bridge-owned repository-backed sorted-container refresh",
|
||
cmd.String(),
|
||
result.Mode,
|
||
result.MovedCount,
|
||
result.StartSlot,
|
||
result.EndSlot,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildSortItemAck(result.ListType)),
|
||
},
|
||
PostActions: []alignedcmd.PostAction{
|
||
postAction,
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedPetEquipmentMoveResult(opcode uint16, cmd Command, result dnfequip.MoveResult) alignedcmd.Result {
|
||
moveValue := cmd.MoveCount
|
||
if result.Mode == "noop" {
|
||
moveValue = 0
|
||
}
|
||
postActions := []alignedcmd.PostAction(nil)
|
||
// The current EXE keeps the worn creature in actor slot 26 as one dedicated
|
||
// client-side object. Its only in-session destroy path is a list-3 op14 row
|
||
// carrying itemID 0xFFFFFFFF: sub_1D73120's list-3 branch then releases the
|
||
// worn object (vtbl+0xC), clears the slot (vtbl+0xB34) and removes the row
|
||
// from the equipment UI ([...+0x334]) in one step. Without that row the
|
||
// object survives an unequip until a full mode1 actor rebuild, which is
|
||
// exactly the reported "pet icon stays in the worn slot until relog".
|
||
// The owner deletes the worn entry only for a plain unequip; unequip_swap
|
||
// writes the displaced item into slot 26, and equip/equip_swap leave the new
|
||
// pet there, so all of those must keep the row untouched. List-3 rows for
|
||
// any other slot are ignored by the same client branch and are never sent.
|
||
// 一个宠物穿脱只改 list 7 与 act 穿戴槽 26,list 0/1/2/38 完全没动:只重建
|
||
// 真正变化的 list 7(见 alignedcmd.PostActionRefreshSelectedWornCreatureInventory
|
||
// 的实测数据),否则客户端会先花 1.7 秒重建那 4 张无关的表,宠物行与 mode0 只能
|
||
// 排在后面,现场就是"图标/宠物要等一下才出现"。
|
||
var itemSlotClears []alignedcmd.ItemSlotRefresh
|
||
if result.Changed {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedWornCreatureInventory)
|
||
if result.DestinationSlotIndex == 26 {
|
||
postActions = append(postActions,
|
||
alignedcmd.PostActionRefreshSelectedCreatureState,
|
||
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
||
)
|
||
if result.Mode == "unequip" {
|
||
itemSlotClears = append(itemSlotClears, alignedcmd.ItemSlotRefresh{
|
||
ListType: listTypeEquipment,
|
||
SlotIndex: 26,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
// Current NoPack op19 keeps positional endpoint semantics (A is the
|
||
// inventory/pet side, B is the worn side), but the owner may redirect A to
|
||
// the next real empty slot while resolving an unequip against stale client
|
||
// inventory state. Report the committed slots while retaining the raw
|
||
// actor-worn endpoint (3 or 17): sub_1D2DE80 has distinct native branches
|
||
// for those endpoint values.
|
||
ackCommand := committedEquipmentMoveAckCommand(cmd, result)
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"equipment owner applied current-EXE pet move: %s equipMove=%s durableSrc=(%d,%d) durableDst=(%d,%d) item=%d swap=%d changed=%t slot26_clear=%d; opened exact op19 ACK then authoritative list7 refresh, plus op105/appearance when target26 changed",
|
||
cmd.String(),
|
||
result.Mode,
|
||
result.SourceListType,
|
||
result.SourceSlotIndex,
|
||
result.DestinationListType,
|
||
result.DestinationSlotIndex,
|
||
result.ItemID,
|
||
result.SwappedItemID,
|
||
result.Changed,
|
||
len(itemSlotClears),
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceSuccessAck(ackCommand, moveValue)),
|
||
},
|
||
PostActions: postActions,
|
||
PostResponseItemSlotClears: itemSlotClears,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedEquipmentMoveResult(opcode uint16, cmd Command, result dnfequip.MoveResult) alignedcmd.Result {
|
||
moveValue := cmd.MoveCount
|
||
if result.Mode == "noop" {
|
||
moveValue = 0
|
||
}
|
||
postActions := []alignedcmd.PostAction(nil)
|
||
if result.Changed {
|
||
// Current NoPack applies a worn avatar/equipment change through the
|
||
// selected actor's complete mode0 appearance table. It must contain all
|
||
// fourteen base slots, including UINT32_MAX for an empty skin/avatar slot,
|
||
// so the client removes the previous paper-doll layer and attaches the
|
||
// committed one in the same town scene. The bridge suppresses only the
|
||
// already-existing actor's initialization event; it does not replay any
|
||
// profession, skill, container, or DLL-side visual action.
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorAppearance)
|
||
// Slot 24 is outside the fourteen-slot mode0 base table. Current NoPack
|
||
// reconciles these occupied rows through class0/op342 and then asks the
|
||
// already-bound actor to redraw through op357. Keep that projection
|
||
// narrowly scoped to moves which actually change the durable slot 24;
|
||
// ordinary equipment continues to use the proven mode0-only path.
|
||
if equipmentMoveTouchesActorSlot(result, 24) {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorExpandedEquipment)
|
||
}
|
||
}
|
||
// Current NoPack op19 keeps positional endpoint semantics (A is the
|
||
// inventory/avatar/cargo side, B is the worn side). The equipment owner
|
||
// preserves that order in MoveResult while replacing A with the actual
|
||
// committed empty slot when an unequip destination was stale. Keep the
|
||
// request's raw actor-worn endpoint because current NoPack distinguishes
|
||
// list 3 from list 17 even though both share the durable equipment owner.
|
||
ackCommand := committedEquipmentMoveAckCommand(cmd, result)
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"equipment owner applied endpoint move: %s equipMove=%s requestA=(%d,%d) requestB=(%d,%d) finalA=(%d,%d) finalB=(%d,%d) item=%d swap=%d changed=%t; opened current-EXE op19 ACK then event-suppressed complete mode0 paper-doll appearance projection without item-container replay",
|
||
cmd.String(),
|
||
result.Mode,
|
||
cmd.SourceListType,
|
||
cmd.SourceSlotIndex,
|
||
cmd.DestinationListType,
|
||
cmd.DestinationSlotIndex,
|
||
result.SourceListType,
|
||
result.SourceSlotIndex,
|
||
result.DestinationListType,
|
||
result.DestinationSlotIndex,
|
||
result.ItemID,
|
||
result.SwappedItemID,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceSuccessAck(ackCommand, moveValue)),
|
||
},
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
func equipmentMoveTouchesActorSlot(result dnfequip.MoveResult, slot int16) bool {
|
||
return (result.SourceListType == listTypeEquipment && result.SourceSlotIndex == slot) ||
|
||
(result.DestinationListType == listTypeEquipment && result.DestinationSlotIndex == slot)
|
||
}
|
||
|
||
func isProvenPetEquipmentMove(cmd Command) bool {
|
||
if cmd.SourceListType != listTypePet || !isActorWornEndpoint(cmd.DestinationListType) {
|
||
return false
|
||
}
|
||
if cmd.DestinationSlotIndex == 26 {
|
||
return cmd.SourceSlotIndex >= 0 && cmd.SourceSlotIndex <= 139
|
||
}
|
||
return cmd.DestinationSlotIndex >= 27 && cmd.DestinationSlotIndex <= 29 &&
|
||
cmd.SourceSlotIndex >= 140 && cmd.SourceSlotIndex <= 188
|
||
}
|
||
|
||
func isActorWornEndpoint(listType byte) bool {
|
||
return listType == listTypeEquipment || listType == listTypeActorWornAlt
|
||
}
|
||
|
||
func petMoveTouchesWornCreatureSlot(cmd Command, result dnfequip.MoveResult) bool {
|
||
return isWornCreatureSlot(cmd.SourceListType, cmd.SourceSlotIndex) ||
|
||
isWornCreatureSlot(cmd.DestinationListType, cmd.DestinationSlotIndex) ||
|
||
isWornCreatureSlot(result.SourceListType, result.SourceSlotIndex) ||
|
||
isWornCreatureSlot(result.DestinationListType, result.DestinationSlotIndex)
|
||
}
|
||
|
||
func isWornCreatureSlot(listType byte, slotIndex int16) bool {
|
||
return isActorWornEndpoint(listType) && slotIndex == 26
|
||
}
|
||
|
||
func committedEquipmentMoveAckCommand(cmd Command, result dnfequip.MoveResult) Command {
|
||
ack := cmd
|
||
ack.SourceListType = result.SourceListType
|
||
ack.SourceSlotIndex = result.SourceSlotIndex
|
||
ack.DestinationListType = result.DestinationListType
|
||
ack.DestinationSlotIndex = result.DestinationSlotIndex
|
||
if isActorWornEndpoint(cmd.SourceListType) && isActorWornEndpoint(result.SourceListType) {
|
||
ack.SourceListType = cmd.SourceListType
|
||
}
|
||
if isActorWornEndpoint(cmd.DestinationListType) && isActorWornEndpoint(result.DestinationListType) {
|
||
ack.DestinationListType = cmd.DestinationListType
|
||
}
|
||
return ack
|
||
}
|
||
|
||
func validateProvenPetEquipmentMove(cmd Command) error {
|
||
if cmd.MoveCount != 1 || cmd.DestinationStack != 0 || cmd.TrailingState0 != 0 || cmd.TrailingState1 != 0 {
|
||
return fmt.Errorf("invalid current-EXE pet op19 constants: count=%d destination_stack=%d tail=(%d,%d)", cmd.MoveCount, cmd.DestinationStack, cmd.TrailingState0, cmd.TrailingState1)
|
||
}
|
||
if cmd.ActorIndex < -1 || cmd.ActorIndex > 2 {
|
||
return fmt.Errorf("invalid current-EXE pet op19 actor index: %d", cmd.ActorIndex)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func equipmentMoveCommand(cmd Command) dnfequip.MoveCommand {
|
||
return dnfequip.MoveCommand{
|
||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||
SourceListType: cmd.SourceListType,
|
||
SourceSlotIndex: cmd.SourceSlotIndex,
|
||
SourceInstanceValue: cmd.SourceInstanceValue,
|
||
MoveCount: cmd.MoveCount,
|
||
DestinationListType: cmd.DestinationListType,
|
||
DestinationSlotIndex: cmd.DestinationSlotIndex,
|
||
DestinationInstanceValue: cmd.DestinationInstance,
|
||
}
|
||
}
|
||
|
||
func normalizedEquipmentMoveCommand(cmd Command) dnfequip.MoveCommand {
|
||
ownerCommand := equipmentMoveCommand(cmd)
|
||
if isActorWornEndpoint(ownerCommand.SourceListType) {
|
||
ownerCommand.SourceListType = listTypeEquipment
|
||
}
|
||
if isActorWornEndpoint(ownerCommand.DestinationListType) {
|
||
ownerCommand.DestinationListType = listTypeEquipment
|
||
}
|
||
return ownerCommand
|
||
}
|
||
|
||
func ownerAppliedRepairResult(opcode uint16, cmd Command, result RepairResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s list=%d slot=%d durability=%d->%d cost=%d gold=%d changed=%t; opened 0x0017 response in C# order",
|
||
cmd.String(),
|
||
result.ListType,
|
||
result.SlotIndex,
|
||
result.OldDurability,
|
||
result.NewDurability,
|
||
result.Cost,
|
||
result.UpdatedGold,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildRepairEquipmentAck(cmd.SourceListType, result.SlotIndex, result.UpdatedGold)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedRepairAllResult(opcode uint16, cmd Command, result dnfequip.RepairResult) alignedcmd.Result {
|
||
itemRefreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.RepairedSlots))
|
||
equipmentRefreshes := make([]int16, 0, len(result.RepairedSlots))
|
||
for _, repaired := range result.RepairedSlots {
|
||
if repaired.Equipped {
|
||
equipmentRefreshes = append(equipmentRefreshes, repaired.SlotIndex)
|
||
continue
|
||
}
|
||
itemRefreshes = append(itemRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: listTypeMain,
|
||
SlotIndex: repaired.SlotIndex,
|
||
})
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"equipment owner applied repair-all: %s repaired=%d cost=%d free_repair=%t gold=%d changed=%t; opened 0x0017 slot=0xFFFF response in 86JP order",
|
||
cmd.String(),
|
||
result.RepairedCount,
|
||
result.Cost,
|
||
result.FreeRepair,
|
||
result.UpdatedGold,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildRepairEquipmentAck(cmd.SourceListType, -1, result.UpdatedGold)),
|
||
},
|
||
EquipmentSlotRefreshes: equipmentRefreshes,
|
||
ItemSlotRefreshes: itemRefreshes,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedEquippedRepairResult(opcode uint16, cmd Command, result dnfequip.RepairResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"equipment owner applied: %s equipSlot=%d item=%d durability=%d->%d cost=%d free_repair=%t gold=%d changed=%t; opened 0x0017 response in C# order",
|
||
cmd.String(),
|
||
result.SlotIndex,
|
||
result.ItemID,
|
||
result.OldDurability,
|
||
result.NewDurability,
|
||
result.Cost,
|
||
result.FreeRepair,
|
||
result.UpdatedGold,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildRepairEquipmentAck(cmd.SourceListType, result.SlotIndex, result.UpdatedGold)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedUseStackableResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
responses := []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableSuccessAck(result)),
|
||
}
|
||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||
var itemSlotRefreshes []alignedcmd.ItemSlotRefresh
|
||
if result.Changed {
|
||
itemSlotRefreshes = append(itemSlotRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
})
|
||
}
|
||
for _, slot := range result.RandomRewardSlots {
|
||
itemSlotRefreshes = append(itemSlotRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: listTypeMain,
|
||
SlotIndex: slot,
|
||
})
|
||
}
|
||
reason := fmt.Sprintf(
|
||
"inventory owner applied: %s item=%d remaining=%d pvf_path=%q stackable_type=%q changed=%t; opened exact current-EXE class1/op44 success response + one-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.PVFPath,
|
||
result.StackableType,
|
||
result.Changed,
|
||
)
|
||
if result.TransformationApplied {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied native transformation: %s item=%d remaining=%d action=%q pvf_path=%q changed=%t; exact current-EXE op44 success invokes the item's client PVF action, followed by one-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.TransformationAction,
|
||
result.PVFPath,
|
||
result.Changed,
|
||
)
|
||
}
|
||
if result.ProjectileApplied {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied native projectile: %s item=%d remaining=%d effect_id=%d pvf_path=%q changed=%t; exact current-EXE op44 success invokes the item's client PVF projectile action, followed by one-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.ProjectileEffectID,
|
||
result.PVFPath,
|
||
result.Changed,
|
||
)
|
||
}
|
||
if result.AvatarFittingRoomOpened {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner opened avatar fitting room: %s ticket=%d available=%d pvf_path=%q; op44 acknowledges the proven ticket without consuming it, op1488 owns the later per-part debit",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.PVFPath,
|
||
)
|
||
}
|
||
// 城镇增益药剂(史诗Buff药剂一族):把增益窗口写进日志,运营在游戏里点一次之后
|
||
// 直接用 "epic_drop_rate_buff" 搜日志就能确认"药吃了、窗口落了、到期时间是多少"。
|
||
if result.StatChangePotionApplied {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied stat-change potion: %s item=%d remaining=%d duration=%ds epic_drop_rate_buff_percent=%d epic_drop_rate_buff_expire_at=%d pvf_path=%q changed=%t; atomic source decrement + character drop-rate window, then class1 op44 ACK and one-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.StatChangePotionSeconds,
|
||
result.EpicDropRatePercent,
|
||
result.EpicDropRateExpireAt,
|
||
result.PVFPath,
|
||
result.Changed,
|
||
)
|
||
}
|
||
if result.CustomAbilityGroupConverterOpened {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner opened custom-ability group converter: %s device=%d available=%d pvf_path=%q; op44 proves the source without consuming it and op1340 owns the later atomic option replacement plus one-device debit",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.PVFPath,
|
||
)
|
||
}
|
||
if result.DungeonAdmissionTicketApplied {
|
||
reason = fmt.Sprintf(
|
||
"inventory/admission settlement owner applied: %s ticket=%d remaining=%d action=%q policy_count=%d bonus_each=%d remaining_daily_uses=%d; atomic source decrement + service-window allowance, then native op44 action and one-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.DungeonAdmissionAction,
|
||
result.DungeonAdmissionPolicyCount,
|
||
result.DungeonAdmissionBonus,
|
||
result.DungeonAdmissionRemainingUses,
|
||
)
|
||
}
|
||
if result.PremiumActivated {
|
||
// Current EXE class0/op66 (sub_1D61460) reads u16 sub-op + u8 type +
|
||
// raw[8] expiry; sub-op 2 is the activate/set path (86JP
|
||
// PremiumService.ActivateAndNotify body matches it exactly). The 0x0312
|
||
// service-data blob is old-client-only (current 786 = skill quick slot
|
||
// sort) and is never pushed. Internal devil slot types 580..587 cannot
|
||
// fit this body and are deliberately suppressed.
|
||
activations := result.PremiumActivations
|
||
if len(activations) == 0 {
|
||
activations = []PremiumActivation{{
|
||
PremiumType: result.PremiumType,
|
||
RemainingSeconds: result.PremiumRemainingSeconds,
|
||
}}
|
||
}
|
||
for _, activation := range activations {
|
||
if premium.CanNotifyActivation(activation.PremiumType) {
|
||
responses = append(responses, class0Response(
|
||
premiumActivatedNotifyMsgID,
|
||
premium.BuildActivatedBody(activation.PremiumType, activation.RemainingSeconds),
|
||
))
|
||
}
|
||
}
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied: %s contract item=%d remaining=%d premium_type=%d premium_remaining=%ds changed=%t; opened op44 ACK + NOTI 0x0042",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.PremiumType,
|
||
result.PremiumRemainingSeconds,
|
||
result.Changed,
|
||
)
|
||
if result.PremiumType == premium.TypeCrystal {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshCrystalContractState)
|
||
}
|
||
}
|
||
if result.ExperienceBonusActivated {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied: %s experience consumable item=%d remaining=%d bonus_percent=%d effect_remaining=%ds changed=%t; atomic op44 decrement + account effect activation",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.ExperienceBonusPercent,
|
||
result.ExperienceBonusRemainingSeconds,
|
||
result.Changed,
|
||
)
|
||
}
|
||
if result.DirectExperienceApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedCharacterProgression)
|
||
reason = fmt.Sprintf(
|
||
"inventory/progression settlement owner applied: %s direct-experience item=%d remaining=%d gain=%d level=%d->%d total=%d->%d sp_gain=%d tp_gain=%d; atomic source decrement + character EXP/level + SP/TP ledger, then op44 ACK/source op14/native skill refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.DirectExperienceGain,
|
||
result.DirectExperiencePreviousLevel,
|
||
result.DirectExperienceNewLevel,
|
||
result.DirectExperiencePreviousTotal,
|
||
result.DirectExperienceNewTotal,
|
||
result.DirectExperienceSPGain,
|
||
result.DirectExperienceTPGain,
|
||
)
|
||
}
|
||
if result.TPBookApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorSkills)
|
||
reason = fmt.Sprintf(
|
||
"inventory/skill settlement owner applied: %s SP/TP book item=%d remaining=%d kind=%d sp_delta=%d tp_delta=%d tp_reset=%t available_sp=%d->%d available_tp=%d->%d; atomic source decrement + point bonus/reset + every skill-tree ledger, then op44 ACK/source op14/native op19 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.SkillPointBookKind,
|
||
result.SkillPointBookSPDelta,
|
||
result.SkillPointBookTPDelta,
|
||
result.SkillPointBookTPReset,
|
||
result.SkillPointBookPreviousSP,
|
||
result.SkillPointBookNewSP,
|
||
result.SkillPointBookPreviousTP,
|
||
result.SkillPointBookNewTP,
|
||
)
|
||
}
|
||
if result.LevelUpTicketApplied {
|
||
reason = fmt.Sprintf(
|
||
"inventory/progression/quest settlement owner applied: %s level-up ticket item=%d range=%d remaining=%d level=%d->%d total=%d->%d sp_gain=%d tp_gain=%d completed_pre_upgrade_quests=%d; atomic source decrement + character EXP/level + SP/TP + no-reward quest completion, then op44 ACK/source op14/native op37/op19/quest refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.LevelUpTicketRange,
|
||
result.RemainingCount,
|
||
result.DirectExperiencePreviousLevel,
|
||
result.DirectExperienceNewLevel,
|
||
result.DirectExperiencePreviousTotal,
|
||
result.DirectExperienceNewTotal,
|
||
result.DirectExperienceSPGain,
|
||
result.DirectExperienceTPGain,
|
||
result.QuestCompletionCount,
|
||
)
|
||
}
|
||
if result.ReviveCoinWalletUpdated {
|
||
itemSlotRefreshes = append(itemSlotRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: dnfrepo.MainInventoryListType,
|
||
SlotIndex: dnfrevivecoin.WalletSlot,
|
||
})
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied: %s revive-coin consumable item=%d remaining=%d wallet_total=%d changed=%t; atomic source decrement + slot1 wallet increment, then exact op44 ACK + two-row op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.ReviveCoinWalletTotal,
|
||
result.Changed,
|
||
)
|
||
}
|
||
if result.RandomRewardItemID > 0 {
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied: %s random reward item=%d remaining=%d reward=%d slots=%v; opened exact op44 ACK then refreshed source and reward rows",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.RandomRewardItemID,
|
||
result.RandomRewardSlots,
|
||
)
|
||
}
|
||
if result.PetFeedApplied {
|
||
// The current client consumes the remaining stack count from the op44
|
||
// acknowledgement. A list-7 op14 row immediately after that ACK can
|
||
// invalidate the pet panel's native object, so publish the authoritative
|
||
// op105/op102 growth state through the bridge instead.
|
||
itemSlotRefreshes = nil
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedCreatureGrowthState)
|
||
reason = fmt.Sprintf(
|
||
"inventory owner applied: %s pet feed item=%d remaining=%d pet=%s satiety=%d->%d applied_delta=%d skill_charge=%d%% duration=%ds; atomic list7 decrement + pet-state update, then exact op44 ACK + immediate op105/op102 creature refresh (item rows suppressed)",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.PetFeedPetKey,
|
||
result.PetFeedSatietyBefore,
|
||
result.PetFeedSatietyAfter,
|
||
result.PetFeedSatietyDelta,
|
||
result.PetFeedSkillChargePercent,
|
||
result.PetFeedEffectDurationSeconds,
|
||
)
|
||
}
|
||
if result.QuestCompletionTicketApplied && !result.LevelUpTicketApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedQuestState)
|
||
if result.QuestCompletionProfessionChange {
|
||
postActions = append(postActions,
|
||
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
||
alignedcmd.PostActionRefreshSelectedActorSkills,
|
||
)
|
||
}
|
||
reason = fmt.Sprintf(
|
||
"inventory/quest settlement owner applied: %s quest-ticket item=%d action=%q completed=%d remaining=%d profession_changed=%t; atomic source decrement + no-reward quest completion, then op44 ACK/source op14/native quest snapshots",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.QuestCompletionAction,
|
||
result.QuestCompletionCount,
|
||
result.RemainingCount,
|
||
result.QuestCompletionProfessionChange,
|
||
)
|
||
}
|
||
if result.StoryBookApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedQuestState)
|
||
reason = fmt.Sprintf(
|
||
"inventory/quest settlement owner applied: %s story-book item=%d quest=%d trigger=%d remaining=%d; atomic source decrement + PVF quest acceptance, then op44 ACK/source op14/native quest refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.StoryBookQuestID,
|
||
result.StoryBookInitTrigger,
|
||
result.RemainingCount,
|
||
)
|
||
}
|
||
alignedResult := alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: reason,
|
||
UpperResponses: responses,
|
||
ItemSlotRefreshes: itemSlotRefreshes,
|
||
PostActions: postActions,
|
||
// A [total usable count] item must publish its used count here: the
|
||
// client only ever learns 每天可用次数 from op541.
|
||
DailyUseStates: dailyUseStates(result),
|
||
// 冷却窗口只上报给 bridge 记账/打日志:客户端自己按同一份 PVF 起本地冷却,
|
||
// 重上之后的恢复走类 0 通知 172(item_cooltime_projection.go)。
|
||
ItemCooltimeStates: cooltimeStates(result),
|
||
}
|
||
// 城镇增益药剂:使用这一发**只补发 buff 注册**(class0/op487 CHARACTER_ADD_BUFF)——
|
||
// buff 栏图标就是它画出来的。协议形状与参考服务端 A21 的 EpicBuffPotion 一致,
|
||
// 协议号按**本客户端**的通知名表定标(基址 485,见 stat_change_potion_projection.go)。
|
||
//
|
||
// 这里**故意不发** class0/op174(load effect item info):服主 2026-10-03 反馈
|
||
// "药剂刚能用那一版(服务端什么包都没发)提示只有一行,后来变成两行"。原因就是
|
||
// 客户端自己按本地使用(op44)登记了效果道具并打了一行,op174 再登记一次就成了
|
||
// 第二行 —— 这一点与既有经验药水路径的注释一致:
|
||
//
|
||
// sendCurrentExperienceConsumableBuffProjection: "First use is handled locally
|
||
// by op44; op174 recreates the same item-owned effect with its durable
|
||
// remaining duration."(只在登录/重选恢复时发)
|
||
//
|
||
// 所以 op174 走"恢复"路径(bridge 的 sendCurrentStatChangePotionBuffProjection),
|
||
// 使用路径不再发。
|
||
// 使用路径只发 buff 注册(图标),数量=1;op174 只在恢复路径发(见上面的注释)。
|
||
//
|
||
// 强化/增幅成功率药剂走**同一条管道**(服主:"套路都是一样的"):它们同样有 buff id
|
||
// (赛丽亚的幸运强化药剂掷到 1097/1098/1099)与到期时刻,客户端同样按 buff id 画图标,
|
||
// 所以投影结构一模一样,只是数据来自 UpgradeRate* 而不是 EpicDropRate*。
|
||
if result.StatChangePotionApplied {
|
||
buffID, expireAtUnix, removeBuffID := int64(0), int64(0), int64(0)
|
||
switch {
|
||
case result.EpicDropRateExpireAt > 0 && result.EpicDropRateBuffID > 0:
|
||
buffID, expireAtUnix = result.EpicDropRateBuffID, result.EpicDropRateExpireAt
|
||
removeBuffID = result.PreviousEpicDropRateBuffID
|
||
case result.UpgradeRateExpireAt > 0 && result.UpgradeRateBuffID > 0:
|
||
buffID, expireAtUnix = result.UpgradeRateBuffID, result.UpgradeRateExpireAt
|
||
removeBuffID = result.PreviousUpgradeRateBuffID
|
||
case result.DungeonClearExpExpireAt > 0 && result.DungeonClearExpBuffID > 0:
|
||
buffID, expireAtUnix = result.DungeonClearExpBuffID, result.DungeonClearExpExpireAt
|
||
case result.ClientBuffExpireAt > 0 && result.ClientBuffID > 0:
|
||
// 客户端自施放的那一族(城镇移速 / 进图属性):同样要把 buff 注册过去,
|
||
// 客户端才会画图标并按自己的 buff 表施加效果。
|
||
buffID, expireAtUnix = result.ClientBuffID, result.ClientBuffExpireAt
|
||
}
|
||
if buffID > 0 && buffID <= math.MaxInt32 {
|
||
projection := &alignedcmd.CharacterAddBuffProjection{
|
||
BuffID: int32(buffID),
|
||
ExpireAtUnix: expireAtUnix,
|
||
}
|
||
if removeBuffID > 0 && removeBuffID != buffID && removeBuffID <= math.MaxInt32 {
|
||
projection.RemoveBuffID = int32(removeBuffID)
|
||
}
|
||
alignedResult.CharacterAddBuff = projection
|
||
}
|
||
}
|
||
if result.CustomAbilityGroupConverterOpened {
|
||
alignedResult.CustomAbilityGroupConverter = &alignedcmd.CustomAbilityGroupConverterProjection{
|
||
CharacterID: cmd.SelectedCharacterID,
|
||
SourceListType: result.ListType,
|
||
SourceSlotIndex: result.SlotIndex,
|
||
SourceInstance: result.InstanceValue,
|
||
SourceItemID: result.ItemID,
|
||
}
|
||
}
|
||
return alignedResult
|
||
}
|
||
|
||
func appliedEventServerLevelUpResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory/progression/quest settlement owner applied current NoPack op418: %s item=%d range=%d remaining=%d gain=%d level=%d->%d sp_gain=%d tp_gain=%d completed_pre_upgrade_quests=%d; exact native delta ACK then source op14 and absolute progression projections",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.LevelUpTicketRange,
|
||
result.RemainingCount,
|
||
result.DirectExperienceGain,
|
||
result.DirectExperiencePreviousLevel,
|
||
result.DirectExperienceNewLevel,
|
||
result.DirectExperienceSPGain,
|
||
result.DirectExperienceTPGain,
|
||
result.QuestCompletionCount,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildEventServerLevelUpSuccessAck(result)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
PostActions: []alignedcmd.PostAction{
|
||
alignedcmd.PostActionRefreshSelectedCharacterProgression,
|
||
},
|
||
}
|
||
}
|
||
|
||
const increaseStatusFailureCode byte = 17
|
||
|
||
// increaseStatusAck picks the op30 branch from the committed result. SP and TP
|
||
// point gains carry their amount in the u32 and select the matching HUD gauge
|
||
// and message string; every other outcome the client has no branch for — a TP
|
||
// reset and every experience book — acknowledges silently so only the following
|
||
// op19/op37 projections speak.
|
||
//
|
||
// sourceSlot is the main-inventory slot the book was consumed from. It is not
|
||
// cosmetic: the client looks it up in its main-inventory array and consumes that
|
||
// row for every kind (see buildIncreaseStatusSuccessAck), so sending anything
|
||
// else — the character id, as this used to — consumes whatever item happens to
|
||
// sit at that index.
|
||
func increaseStatusAck(sourceSlot uint16, result UseStackableResult) []byte {
|
||
switch {
|
||
case result.TPBookApplied && result.SkillPointBookSPDelta > 0:
|
||
return buildIncreaseStatusSuccessAck(sourceSlot, increaseStatusKindSP, uint32(result.SkillPointBookSPDelta))
|
||
case result.TPBookApplied && result.SkillPointBookTPDelta > 0:
|
||
return buildIncreaseStatusSuccessAck(sourceSlot, increaseStatusKindTP, uint32(result.SkillPointBookTPDelta))
|
||
default:
|
||
return buildIncreaseStatusSuccessAck(sourceSlot, increaseStatusKindSilent, 0)
|
||
}
|
||
}
|
||
|
||
func appliedIncreaseStatusResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||
reason := ""
|
||
if result.TPBookApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorSkills)
|
||
reason = fmt.Sprintf(
|
||
"inventory/skill settlement owner applied current NoPack op30: %s SP/TP book item=%d remaining=%d kind=%d sp_delta=%d tp_delta=%d tp_reset=%t available_sp=%d->%d available_tp=%d->%d; class1 op30 gain ACK then source op14 and native op19 refresh",
|
||
cmd.String(), result.ItemID, result.RemainingCount, result.SkillPointBookKind,
|
||
result.SkillPointBookSPDelta, result.SkillPointBookTPDelta, result.SkillPointBookTPReset,
|
||
result.SkillPointBookPreviousSP, result.SkillPointBookNewSP,
|
||
result.SkillPointBookPreviousTP, result.SkillPointBookNewTP,
|
||
)
|
||
}
|
||
if result.DirectExperienceApplied {
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedCharacterProgression)
|
||
reason = fmt.Sprintf(
|
||
"inventory/progression settlement owner applied current NoPack op30: %s experience book item=%d remaining=%d gain=%d level=%d->%d total=%d->%d sp_gain=%d tp_gain=%d; silent class1 op30 ACK then source op14 and the absolute progression projections that own the experience bar and level-up presentation",
|
||
cmd.String(), result.ItemID, result.RemainingCount, result.DirectExperienceGain,
|
||
result.DirectExperiencePreviousLevel, result.DirectExperienceNewLevel,
|
||
result.DirectExperiencePreviousTotal, result.DirectExperienceNewTotal,
|
||
result.DirectExperienceSPGain, result.DirectExperienceTPGain,
|
||
)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: reason,
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, increaseStatusAck(uint16(cmd.SourceSlotIndex), result)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
// rejectedIncreaseStatusResult must always answer. An unanswered op30 leaves
|
||
// the client parked in its item wait state, after which every later action
|
||
// reports "请关闭正在使用的其他窗口后再尝试" until the character returns to select.
|
||
func rejectedIncreaseStatusResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"parsed %s but the increase-status book owner rejected it: %v; returned current NoPack op30 error 17 to release the pending item-use state",
|
||
cmd.String(),
|
||
err,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildIncreaseStatusFailureAck(increaseStatusFailureCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
const useStackableSilentFailureCode byte = 17
|
||
|
||
const useStackableActionFailureCode byte = 17
|
||
|
||
const eventServerLevelUpFailureCode byte = 17
|
||
|
||
func rejectedEventServerLevelUpResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"parsed %s but event-server level-up owner rejected it: %v; returned current NoPack op418 error 17 to release the pending coupon state",
|
||
cmd.String(),
|
||
err,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildEventServerLevelUpFailureAck(eventServerLevelUpFailureCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerRejectedUseStackableResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"parsed %s but inventory owner rejected it: %v; returned current-EXE op44 silent error 17 to release pending item-use state",
|
||
cmd.String(),
|
||
err,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableFailureAck(cmd, useStackableSilentFailureCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerRejectedUseStackableActionResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"parsed %s but stackable-action owner rejected it: %v; returned current-EXE op515 error 17 to release the shared pending item-operation state",
|
||
cmd.String(),
|
||
err,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionFailureAck(useStackableActionFailureCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedDungeonAdmissionTicketActionResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory/admission settlement owner applied: %s ticket=%d remaining=%d action=%q policy_count=%d bonus_each=%d remaining_daily_uses=%d; atomic source decrement + service-window allowance, then native op515 action-1 ACK and source op14 refresh",
|
||
cmd.String(), result.ItemID, result.RemainingCount, result.DungeonAdmissionAction,
|
||
result.DungeonAdmissionPolicyCount, result.DungeonAdmissionBonus, result.DungeonAdmissionRemainingUses,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{ListType: result.ListType, SlotIndex: result.SlotIndex}},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedSharedEffectItemResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
itemSlotRefreshes := []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}}
|
||
for _, slot := range result.RandomRewardSlots {
|
||
itemSlotRefreshes = append(itemSlotRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: listTypeMain,
|
||
SlotIndex: slot,
|
||
})
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s item=%d remaining=%d reward=%d reward_slots=%v pvf_path=%q changed=%t; opened exact current-EXE class1/type251 result-byte ACK then source/reward op14 refresh",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.RandomRewardItemID,
|
||
result.RandomRewardSlots,
|
||
result.PVFPath,
|
||
result.Changed,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseSharedEffectItemSuccessAck()),
|
||
},
|
||
ItemSlotRefreshes: itemSlotRefreshes,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedDamageFontUnlockResult(opcode uint16, cmd Command, result DamageFontUnlockResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"damage-font owner applied: slot=%d item=%d font=%d expires_at=%d remaining=%d pvf_path=%q; op515 ACK then op14 slot refresh then NOTI 1239",
|
||
result.SourceSlotIndex, result.ItemID, result.FontIndex, result.ExpiresAt, result.RemainingCount, result.PVFPath,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SourceSlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: listTypeMain,
|
||
SlotIndex: result.SourceSlotIndex,
|
||
}},
|
||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedDamageFontState},
|
||
}
|
||
}
|
||
|
||
// dailyUseStates is the client-facing 每日可用次数 state one capped item use
|
||
// produces, for ANY item whose PVF definition carries [total usable count].
|
||
//
|
||
// The byte is the **used** count. Live 2026-09-15 settled it: with one potion
|
||
// already spent (durable counter 1) the server sent 2, and the item tooltip
|
||
// rendered "1/3" -- i.e. 上限 - 该字节, exactly the arithmetic the client's own
|
||
// accessor sub_01F17F70 performs ([item+0x470] - [item+0x46C]). The earlier
|
||
// "inverted" reading came from the packet being ignored entirely while it was
|
||
// sent as class 1.
|
||
func dailyUseStates(result UseStackableResult) []alignedcmd.DailyUseState {
|
||
itemID := result.DailyUseItemID
|
||
if itemID <= 0 {
|
||
itemID = result.ItemID
|
||
}
|
||
if result.DailyUseLimit <= 0 || result.DailyUseUsed < 0 || itemID <= 0 || itemID > 0xFFFFFFFF {
|
||
return nil
|
||
}
|
||
used := result.DailyUseUsed
|
||
if used > 0xFF {
|
||
used = 0xFF
|
||
}
|
||
return []alignedcmd.DailyUseState{{ItemID: uint32(itemID), Used: byte(used)}}
|
||
}
|
||
|
||
// cooltimeStates is the durable cooldown window one use started, for ANY item
|
||
// whose PVF declares `[cool time]` together with `[cooltime maintenance]`.
|
||
//
|
||
// The client's own gate lives in process memory only (cmd/scratch/potionpatch:
|
||
// "no packet is sent while the client blocks the action"), so a 小退/重上 wipes
|
||
// it. The window therefore has to be reported by the server, and the bridge logs
|
||
// it as the evidence that the durable half of the contract exists.
|
||
func cooltimeStates(result UseStackableResult) []alignedcmd.ItemCooltimeState {
|
||
if result.CooltimeItemID <= 0 || result.CooltimeExpireAt <= 0 {
|
||
return nil
|
||
}
|
||
return []alignedcmd.ItemCooltimeState{{
|
||
ItemID: result.CooltimeItemID,
|
||
ExpireAt: result.CooltimeExpireAt,
|
||
}}
|
||
}
|
||
|
||
func ownerAppliedFatiguePotionActionResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"fatigue-potion owner applied: %s item=%d recovery=%d fatigue=%d->%d remaining=%d; op515 ACK/source op14 then absolute fatigue refresh",
|
||
cmd.String(), result.ItemID, result.FatigueRecovery, result.FatigueBefore, result.FatigueAfter, result.RemainingCount,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedFatigue},
|
||
DailyUseStates: dailyUseStates(result),
|
||
}
|
||
}
|
||
|
||
func ownerAppliedRentalPointActionResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"rental-point owner applied: %s item=%d granted=%d wallet=%d->%d limit=%d at_limit=%t remaining=%d; op515 action-105 ACK/source op14 then absolute op985 wallet refresh",
|
||
cmd.String(), result.ItemID, result.RentalPointGranted, result.RentalPointBefore,
|
||
result.RentalPointAfter, result.RentalPointLimit, result.RentalPointAtLimit, result.RemainingCount,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedRentalWallet},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedQuestCompletionTicketActionResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
postActions := []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedQuestState}
|
||
if result.QuestCompletionProfessionChange {
|
||
postActions = append(postActions,
|
||
alignedcmd.PostActionRefreshSelectedActorAppearance,
|
||
alignedcmd.PostActionRefreshSelectedActorSkills,
|
||
)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory/quest settlement owner applied: %s quest-ticket item=%d action=%q completed=%d remaining=%d profession_changed=%t; atomic source decrement + no-reward quest completion, then op515 ACK/source op14/native quest snapshots",
|
||
cmd.String(),
|
||
result.ItemID,
|
||
result.QuestCompletionAction,
|
||
result.QuestCompletionCount,
|
||
result.RemainingCount,
|
||
result.QuestCompletionProfessionChange,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
// ownerAppliedContainerUpgradeResult 是扩容券落地后的回包与投影计划。
|
||
//
|
||
// 顺序沿用 cera_shop_purchase.go 那条已在游戏里验过的容量类路径:
|
||
//
|
||
// 背包(list 0) → 券槽位 op14 增量清行 + list 0 整表重建
|
||
// 金库(list 2) → 券槽位 op14 + op391 重建仓库底板 + list 2 整表(先底板后列表)
|
||
// 帐号金库(list12)→ 券槽位 op14 + op391 重建仓库底板 + list 12 整表
|
||
//
|
||
// PostActions 是**按切片顺序**执行的,所以"底板 → 列表"这两发必须挨着且顺序不能反。
|
||
func ownerAppliedContainerUpgradeResult(opcode uint16, cmd Command, result UseStackableResult) alignedcmd.Result {
|
||
postActions := make([]alignedcmd.PostAction, 0, 2)
|
||
if result.ContainerUpgradeNeedsCargoPad {
|
||
postActions = append(postActions, alignedcmd.PostActionResetCargoPadBeforeItemLists)
|
||
}
|
||
switch result.ContainerUpgradeListType {
|
||
case listTypePersonalCargo:
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedPersonalCargo)
|
||
case listTypeAccountCargo:
|
||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedAccountCargo)
|
||
}
|
||
var itemListRebuilds []byte
|
||
if result.ContainerUpgradeListType == listTypeMain {
|
||
itemListRebuilds = []byte{listTypeMain}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory container-upgrade owner applied: %s kind=%s voucher=%d remaining=%d %d->%d list=%d cargo_pad=%t pvf_path=%q; atomic voucher decrement + container tier advance, then class1 ACK + source op14 + container rebuild",
|
||
cmd.String(),
|
||
result.ContainerUpgradeKind,
|
||
result.ItemID,
|
||
result.RemainingCount,
|
||
result.ContainerUpgradeBefore,
|
||
result.ContainerUpgradeAfter,
|
||
result.ContainerUpgradeListType,
|
||
result.ContainerUpgradeNeedsCargoPad,
|
||
result.PVFPath,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildUseStackableActionSuccessAck(result.SlotIndex, result.RemainingCount)),
|
||
},
|
||
ItemSlotRefreshes: []alignedcmd.ItemSlotRefresh{{
|
||
ListType: result.ListType,
|
||
SlotIndex: result.SlotIndex,
|
||
}},
|
||
ItemListRebuilds: itemListRebuilds,
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
func ownerAppliedDamageFontSelectionResult(opcode uint16, cmd Command, result DamageFontSelectionResult) alignedcmd.Result {
|
||
body := buildSelectDamageFontErrorAck(damageFontSelectionErrorUnavailable)
|
||
reason := fmt.Sprintf("damage font %d is not owned or has expired; returned current-EXE error 17", result.FontIndex)
|
||
if result.Success {
|
||
body = buildSelectDamageFontSuccessAck(result.FontIndex)
|
||
reason = fmt.Sprintf("selected damage font %d persisted; returned current-EXE op1288 success ACK", result.FontIndex)
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: reason,
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, body),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("已解析 %s:%s;inventory owner 未落库:%v;禁止回成功包", cmd.Operation, cmd.String(), err),
|
||
}
|
||
}
|
||
|
||
const moveItemspaceRejectedErrorCode byte = 17
|
||
|
||
// moveItemspaceDestinationFullErrorCode 是"目标容器放不下这一堆"时回给客户端的 op19
|
||
// 失败码。现网证据:同一客户端把失败码 4 显示成"物品栏空间不足"(modules/dnf/cargo
|
||
// 里帐号金库 64->72 档被拒那次实测),86JP InventoryMoveService 也把 DestinationRejected
|
||
// 这类插入失败映射成 0x04,所以这里取 4。若现场文案不对,改这一个常量即可。
|
||
const moveItemspaceDestinationFullErrorCode byte = 4
|
||
|
||
// ownerRejectedMoveSpaceResult 是和 ownerRejectedQuickSlotCharmMoveResult 同一形状的
|
||
// 失败帧:sub_1D2DE80 消费 result/error 两个字节后还要读源/目标 list owner 才能释放
|
||
// 拖拽状态。不回帧,客户端就只是"没反应"(现场就是仓库满了没提示也放不进去)。
|
||
func ownerRejectedMoveSpaceResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"rejected move without mutation: %s: %v; returned current-EXE op19 error %d (destination container has no room)",
|
||
cmd.String(),
|
||
err,
|
||
moveItemspaceDestinationFullErrorCode,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceFailureAck(cmd, moveItemspaceDestinationFullErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
// moveItemspaceRejectionErrorCode 把 owner 对一次已解析 move 的拒绝原因映射成 op19 的失败码。
|
||
//
|
||
// 容量/栏位一类(目标放不下、超过负重、账号金库栏位未解锁、金库未创建)统一回
|
||
// moveItemspaceDestinationFullErrorCode:现场这台客户端把 4 显示成"物品栏空间不足",
|
||
// 语义上就是"这边放不了";若要改文案,动这一个常量即可。其余拒绝回通用拒绝码。
|
||
func moveItemspaceRejectionErrorCode(err error) byte {
|
||
switch {
|
||
case errors.Is(err, ErrMoveDestinationFull),
|
||
errors.Is(err, ErrMoveCarryLimitExceeded),
|
||
errors.Is(err, ErrAccountCargoSlotOutOfRange),
|
||
errors.Is(err, ErrAccountCargoNotCreated):
|
||
return moveItemspaceDestinationFullErrorCode
|
||
case errors.Is(err, ErrEmblemWarehouseForbidden), errors.Is(err, ErrAccountCargoItemNotAllowed):
|
||
// 徽章禁存仓库与"这件道具不许进金库"是同一类:**不允许**,不是"放不下"。
|
||
// 必须避开失败码 4 —— 那在客户端会显示成「物品栏空间不足」,而仓库明明空着,
|
||
// 玩家会以为是格子问题。这里回通用拒绝码(17),与既有不可交易道具进金库同口径。
|
||
return moveItemspaceRejectedErrorCode
|
||
default:
|
||
return moveItemspaceRejectedErrorCode
|
||
}
|
||
}
|
||
|
||
// ownerRejectedMoveRequestResult 是"请求已经解析成一次 move、但 durable owner 拒绝"的统一
|
||
// 失败帧。
|
||
//
|
||
// 现场 2026-09-18:把道具拖进账号金库**未解锁**的栏位(dst=(12,28),容量 8)时 owner 以
|
||
// ErrAccountCargoSlotOutOfRange 拒绝,旧代码落到 ownerBlockedResult 不回帧。客户端
|
||
// sub_1D2DE80 在收到 class1/op19 之前一直保持 pending 拖拽对象,于是金库/装备/消耗品窗口
|
||
// 全部点不动,而且它此后一个包都不再发(packet_log 里 RECV 从此静默,只剩服务端心跳)。
|
||
// 所以:只要请求已解析成 move,被 owner 拒绝就必须回这一帧——绝不静默。
|
||
func ownerRejectedMoveRequestResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
code := moveItemspaceRejectionErrorCode(err)
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"rejected move without mutation: %s: %v; returned current-EXE op19 error %d with source/destination list owners to release pending drag state",
|
||
cmd.String(),
|
||
err,
|
||
code,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceFailureAck(cmd, code)),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ownerRejectedQuickSlotCharmMoveResult is deliberately narrower than the
|
||
// generic ownerBlockedResult. Current NoPack sub_1D2DE80 consumes the common
|
||
// result/error bytes on a failed class1/op19, then reads the source and
|
||
// destination list owners before releasing its pending drag state. Returning
|
||
// no frame leaves that state armed; returning a success frame enters the native
|
||
// move path even though the transaction rolled back. This exact failure frame
|
||
// rejects only the second PVF [charm] while keeping both durable slots intact.
|
||
func ownerRejectedQuickSlotCharmMoveResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"rejected second quick-slot charm without mutation: %s: %v; returned current-EXE op19 error %d with source/destination list owners to release pending drag state",
|
||
cmd.String(),
|
||
err,
|
||
moveItemspaceRejectedErrorCode,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildMoveItemspaceFailureAck(cmd, moveItemspaceRejectedErrorCode)),
|
||
},
|
||
}
|
||
}
|
||
|
||
func ownerAppliedResult(cmd Command, detail string) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf("已写入 inventory owner:%s %s;缺少 mutation id 与当前 EXE 的 %s/背包刷新/USERINFO 顺序证据,暂不回成功包", cmd.String(), detail, ackEvidenceLabel(cmd.Operation)),
|
||
}
|
||
}
|
||
|
||
func ownerAppliedDeleteResult(opcode uint16, cmd Command, result DeleteResult) alignedcmd.Result {
|
||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||
if result.Changed && result.DeletedAccountMaterial {
|
||
// 副本内删除账号共享晶体/灵魂材料后,bridge 需补空的 S0/0012 完成帧;
|
||
// 是否在副本由 bridge 按 session.dungeon.runtime 判定并在城镇 no-op。
|
||
postActions = append(postActions, alignedcmd.PostActionSendDeleteItemAccountMaterialCompletion)
|
||
}
|
||
if cmd.DeleteStateTrailer {
|
||
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.Removed))
|
||
for _, item := range result.Removed {
|
||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: item.ListType,
|
||
SlotIndex: item.SlotIndex,
|
||
})
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied skill-material delete: %s removed=%d changed=%t accountMaterial=%t; returned empty op18 RETURN then authoritative op14 rows",
|
||
cmd.String(),
|
||
len(result.Removed),
|
||
result.Changed,
|
||
result.DeletedAccountMaterial,
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildDungeonSkillMaterialDeleteAck(true)),
|
||
},
|
||
ItemSlotRefreshes: refreshes,
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
responses := make([]alignedcmd.UpperResponse, 0, 1)
|
||
if len(result.Removed) > 0 {
|
||
responses = append(responses, class1Response(opcode, buildDeleteItemAck(result.Removed)))
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: len(responses) > 0,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"inventory owner applied: %s removed=%d changed=%t accountMaterial=%t; opened 0x0012 response in C# order",
|
||
cmd.String(),
|
||
len(result.Removed),
|
||
result.Changed,
|
||
result.DeletedAccountMaterial,
|
||
),
|
||
UpperResponses: responses,
|
||
PostActions: postActions,
|
||
}
|
||
}
|
||
|
||
// deleteItemLockedErrorCode 是"物品处于锁定状态"在 op18 失败表里的码。
|
||
//
|
||
// 反查结果(客户端通用映射器 0x032DDC70):`cmp eax, 0xd6 / jne` → `push 0;
|
||
// push 0xa4f8` → GetText(0x0367BE00) → show(0x02FDD1A0),即 214 → dstr 42232
|
||
// 「在物品已锁定状态下,您无法进行当前操作。」op18 的失败分支 0x01D21E8B 对表外
|
||
// 的码落到 0x01D21F3B 再 `call 0x32DDC70`,所以 214 在 op18 上会渲染 42232。
|
||
//
|
||
// 之前这里对所有拒绝一律回 4,而 4 在 op18 表里是"要丢弃的物品不存在或数量不足"
|
||
// 之外的通用拒绝 —— 玩家把锁定物品拖去删除时看到的是一句与锁定无关的话(现场
|
||
// 2026-09-15:锁定时点删除/丢弃完全没有提示)。
|
||
const (
|
||
deleteItemFailureErrorCode byte = 4
|
||
deleteItemLockedErrorCode byte = 214
|
||
)
|
||
|
||
func deleteItemRejectionErrorCode(err error) byte {
|
||
if errors.Is(err, ErrItemLocked) {
|
||
return deleteItemLockedErrorCode
|
||
}
|
||
return deleteItemFailureErrorCode
|
||
}
|
||
|
||
func ownerRejectedDeleteResult(opcode uint16, cmd Command, err error) alignedcmd.Result {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: true,
|
||
Operation: cmd.Operation,
|
||
Reason: fmt.Sprintf(
|
||
"parsed %s but delete owner rejected it: %v; returned empty op18 failure RETURN with code %d to release the shared item-operation pending state",
|
||
cmd.String(),
|
||
err,
|
||
deleteItemRejectionErrorCode(err),
|
||
),
|
||
UpperResponses: []alignedcmd.UpperResponse{
|
||
class1Response(opcode, buildDungeonSkillMaterialDeleteAckWithCode(deleteItemRejectionErrorCode(err))),
|
||
},
|
||
}
|
||
}
|
||
|
||
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
|
||
return alignedcmd.UpperResponse{
|
||
MsgID: opcode,
|
||
Body: body,
|
||
Classification: dnfproto.DefaultChannelClassification,
|
||
AllowCodec: true,
|
||
}
|
||
}
|
||
|
||
func class0Response(msgID uint16, body []byte) alignedcmd.UpperResponse {
|
||
return alignedcmd.UpperResponse{
|
||
MsgID: msgID,
|
||
Body: body,
|
||
Classification: 0,
|
||
AllowCodec: true,
|
||
}
|
||
}
|
||
|
||
func ackEvidenceLabel(operation string) string {
|
||
switch operation {
|
||
case "delete_item":
|
||
return "0x0012"
|
||
case "sell_item":
|
||
return "0x0016"
|
||
case "move_itemspace":
|
||
return "0x0013"
|
||
case "sort_item":
|
||
return "0x0014"
|
||
case "repair_equipment":
|
||
return "0x0017"
|
||
default:
|
||
return "对应 ACK"
|
||
}
|
||
}
|
||
|
||
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
|
||
if err != nil {
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: operation,
|
||
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
|
||
}
|
||
}
|
||
return alignedcmd.Result{
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: operation,
|
||
Reason: fmt.Sprintf("已解析 %s:%s;资产写路径需接入 durable owner、事务、幂等和当前 EXE S2C 刷新顺序后才能回包", operation, summary),
|
||
}
|
||
}
|