服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1750 lines
80 KiB
Go
1750 lines
80 KiB
Go
// 本文件定义 DNF 已对齐命令的模块化分流结果。
|
||
// 这里只承载协议分流和回包描述,不持有具体玩法状态。
|
||
package alignedcmd
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfskill "longheng.io/server/internal/modules/dnf/skill"
|
||
)
|
||
|
||
// Request 是 dnfbridge 交给 DNF 业务模块的最小上下文。
|
||
// Body 是当前包体副本,模块不得在原地修改调用方缓冲区。
|
||
type Request struct {
|
||
Command byte
|
||
CommandKnown bool
|
||
Opcode uint16
|
||
Body []byte
|
||
AccountID string
|
||
SelectedCharacterID uint16
|
||
DojoSkills bool
|
||
Repositories dnfrepo.Group
|
||
EquipmentPlacement EquipmentPlacementValidator
|
||
GuardianShield GuardianShieldResolver
|
||
AvatarClassifier AvatarClassifier
|
||
AuroraLookReplaceClassifier AuroraLookReplaceClassifier
|
||
StackMergeResolver StackMergeResolver
|
||
// AccountWarehouseSlotResolver 给出"这类道具的固定晶体/灵魂仓库格"(六个晶块
|
||
// 354..359、六个灵魂 360..365)。op19 用它把落点声明成角色背包的晶体/灵魂改回固定
|
||
// 仓库格;没有固定格的道具返回 false,调用方一律保持客户端声明的落点。
|
||
AccountWarehouseSlotResolver func(itemID int64) (int16, bool)
|
||
// ItemCooltimeGroupResolver 返回某件道具的 PVF `[cooltime group]`(0 = 无组)。
|
||
// op44 的冷却检查用它判断"已存在的冷却行是否与本次使用同组",所以它必须能解析
|
||
// **已经在角色冷却表里的**item id,而不是只有本次使用的那一件。
|
||
ItemCooltimeGroupResolver func(itemID int64) int64
|
||
// StackMergeResolverError preserves the active-PVF catalog failure that
|
||
// prevented dnfbridge from constructing StackMergeResolver. Domain handlers
|
||
// use it only where silently falling back would make a mutation unsafe.
|
||
StackMergeResolverError error
|
||
NameTagChecker func(itemID uint32) bool
|
||
PetHatchResolver PetHatchResolver
|
||
EnchantBeadResolver EnchantBeadResolver
|
||
MonsterCardUpgradeResolver MonsterCardUpgradeResolver
|
||
MonsterCardUpgradePolicy MonsterCardUpgradePolicy
|
||
AmplifyItemResolver AmplifyItemResolver
|
||
RandomOptionResolver RandomOptionResolver
|
||
EquipmentRegenerationResolver EquipmentRegenerationResolver
|
||
UpgradeTicketResolver UpgradeTicketResolver
|
||
UpgradeProtectionResolver UpgradeProtectionResolver
|
||
UpgradePolicyResolver UpgradePolicyResolver
|
||
UpgradeRefundMailer UpgradeRefundMailer
|
||
ForgingResolver ForgingResolver
|
||
ChronicleRefineResolver ChronicleRefineResolver
|
||
MagicBoxResolver MagicBoxResolver
|
||
MagicBoxRewardItemResolver MagicBoxRewardItemResolver
|
||
MagicBoxContractActivator MagicBoxContractActivator
|
||
RandomRewardItemResolver RandomRewardItemResolver
|
||
PremiumContractResolver PremiumContractResolver
|
||
ExperienceConsumableResolver ExperienceConsumableResolver
|
||
StackableUseResolver StackableUseResolver
|
||
DirectExperiencePlanner DirectExperienceConsumablePlanner
|
||
QuestCompletionTicketPlanner QuestCompletionTicketPlanner
|
||
StoryBookPlanner StoryBookPlanner
|
||
ChronicleGrowthResolver ChronicleGrowthResolver
|
||
InDungeon bool
|
||
DungeonID int64
|
||
// ConsumableFree marks a sandbox run (修炼道场) whose consumables are usable
|
||
// but never deducted: stackable use still runs for its effect, the owner just
|
||
// leaves the stack count alone.
|
||
ConsumableFree bool
|
||
DamageFontResolver DamageFontResolver
|
||
DamageFontNow time.Time
|
||
FatiguePotionNow time.Time
|
||
RentalPointNow time.Time
|
||
RepairCostResolver RepairCostResolver
|
||
MailboxItemResolver MailboxItemResolver
|
||
SkillCatalog *dnfskill.Table
|
||
InitialSkillLevels map[uint16]int
|
||
SkillPointBaseline *dnfrepo.SkillPointState
|
||
Party *PartyState
|
||
}
|
||
|
||
// EquipmentPlacementRequest is the dependency-neutral bridge contract used
|
||
// by the inventory module before an item enters a worn equipment slot.
|
||
type EquipmentPlacementRequest struct {
|
||
CharacterID string
|
||
ItemID int64
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
TargetSlotIndex int16
|
||
}
|
||
|
||
// EquipmentPlacementValidator keeps current-PVF access in dnfbridge while
|
||
// allowing the equipment owner to enforce the check inside its item UOW.
|
||
type EquipmentPlacementValidator func(context.Context, EquipmentPlacementRequest) error
|
||
|
||
// GuardianShieldResolution is the active-PVF proof for one item admitted to
|
||
// the Guardian's five-slot shield deck. The bridge resolves archive metadata;
|
||
// the domain owner persists only this typed result inside its character-asset
|
||
// transaction.
|
||
type GuardianShieldResolution struct {
|
||
ItemID uint32
|
||
PVFPath string
|
||
EquipmentType string
|
||
UnlockKind GuardianShieldUnlockKind
|
||
ClearQuestID int64
|
||
MinimumLevel int64
|
||
}
|
||
|
||
type GuardianShieldUnlockKind byte
|
||
|
||
const (
|
||
GuardianShieldUnlockUnknown GuardianShieldUnlockKind = iota
|
||
GuardianShieldUnlockQuest
|
||
GuardianShieldUnlockLevel
|
||
)
|
||
|
||
// GuardianShieldResolver validates shieldwindownewdata membership and its
|
||
// quest/level authorization for the character's normalized grow type together
|
||
// with the concrete equipment file ([support weapon], usable knight, shield
|
||
// group and shield path).
|
||
type GuardianShieldResolver func(context.Context, uint32, byte) (GuardianShieldResolution, error)
|
||
|
||
// AvatarClassifier resolves an equipment template through the active PVF.
|
||
// isAvatar is true only for actor appearance slots; isClone additionally
|
||
// requires the PVF [item category] value `clear avatar`.
|
||
type AvatarClassifier func(itemID uint32) (isAvatar bool, isClone bool)
|
||
|
||
// AuroraLookReplaceClassifier resolves an equipment template through the active
|
||
// PVF and is true only for a 转职光环 -- an `[aurora avatar]` that is also a
|
||
// `clear avatar` item. A 转职光环 is therefore a clone-category item as well,
|
||
// but its semantics are the opposite of 透明克隆时装: it replaces the worn
|
||
// aura's appearance instead of borrowing it, and the covered aura is only the
|
||
// stat/link source. A nil classifier keeps the legacy clone-only behaviour.
|
||
type AuroraLookReplaceClassifier func(itemID uint32) bool
|
||
|
||
// StackMergeResolution is the active-runtime-PVF contract used when two
|
||
// durable item rows are split or merged. Inventory metadata is deliberately
|
||
// not authoritative here: old saves and grants from different systems may
|
||
// carry different tracing keys while still representing the same stackable
|
||
// template.
|
||
type StackMergeResolution struct {
|
||
ItemID int64
|
||
Kind string
|
||
PVFPath string
|
||
// AttachType is the verbatim runtime-PVF `[attach type]` text of the resolved
|
||
// template (for example "`[free]`"). The inventory owner reads it through
|
||
// NormalizeAttachType to decide whether an item may be stored in a container
|
||
// that only accepts tradeable items: the account cargo (list 12) whitelist is
|
||
// free / account / sealing, every other value is untradeable. It must stay
|
||
// verbatim so the domain compares what the active PVF actually said instead of
|
||
// a value already reinterpreted on the bridge side.
|
||
AttachType string
|
||
// EquipmentType is populated for equipment definitions. The inventory
|
||
// owner uses it to enforce the client-visible one-at-a-time rule for
|
||
// quick-slot charms without relying on item names or template IDs.
|
||
EquipmentType string
|
||
StackableType string
|
||
StackLimit int64
|
||
SlotStart int16
|
||
SlotEnd int16
|
||
}
|
||
|
||
// NormalizeAttachType 把 PVF `[attach type]` 的原样文本("`[free]`"、"`[trade delete]`")
|
||
// 归一成小写裸词("free"、"trade delete")。与 dnfbridge 的 normalizeMagicBoxPVFType
|
||
// 同一口径:只剥掉反引号、方括号与空白,不改写取值本身。
|
||
func NormalizeAttachType(value string) string {
|
||
return strings.ToLower(strings.Trim(value, "` []"))
|
||
}
|
||
|
||
// StackMergeResolver resolves one durable template through the active PVF.
|
||
// A nil resolver keeps the legacy Extra-backed behavior for isolated domain
|
||
// tests, while the current game route supplies it for every op19 move.
|
||
type StackMergeResolver func(itemID int64) (StackMergeResolution, error)
|
||
|
||
// PetHatchResolution is the dependency-neutral runtime-PVF result passed from
|
||
// dnfbridge into the pet domain. Keeping this DTO here avoids an alignedcmd ->
|
||
// pet import cycle while still preventing request-body or inventory-Extra
|
||
// values from becoming authoritative hatch mappings.
|
||
type PetHatchResolution struct {
|
||
EggItemID int64
|
||
HatchedItemID int64
|
||
EggPVFPath string
|
||
HatchedPVFPath string
|
||
MinimumLevel int
|
||
}
|
||
|
||
// PetHatchResolver resolves one real inventory egg item through the active
|
||
// runtime PVF. A nil resolver means that the pet mutation must fail closed.
|
||
type PetHatchResolver func(eggItemID int64) (PetHatchResolution, error)
|
||
|
||
// EnchantBeadResolution is the dependency-neutral runtime-PVF result passed
|
||
// from dnfbridge into the inventory domain for one enchant-by-bead attempt.
|
||
// CardItemID is the monster-card/enchant item the bead carries; zero means the
|
||
// bead template carries no enchant card and the request must be rejected as an
|
||
// invalid bead. UpgradeCounts is the card's allowed enchant-upgrade table; an
|
||
// empty table means only upgrade count zero is accepted.
|
||
type EnchantBeadResolution struct {
|
||
CardItemID int64
|
||
CardPVFPath string
|
||
RandomTitleEnchantConfigID uint16
|
||
RandomTitleEnchantRowID uint16
|
||
TargetWhitelist []int64
|
||
AllowedEquipmentTypes []string
|
||
UpgradeCounts []int64
|
||
TargetEquipmentType string
|
||
TargetKind string
|
||
}
|
||
|
||
// EnchantBeadResolver resolves the bead and target templates through the
|
||
// active runtime PVF. A nil resolver means the enchant mutation must fail
|
||
// closed instead of trusting request or inventory-Extra metadata.
|
||
type EnchantBeadResolver func(beadItemID int64, targetItemID int64) (EnchantBeadResolution, error)
|
||
|
||
// MonsterCardUpgradeResolution is the active-PVF authority for one card
|
||
// template used by current NoPack op603. The client request contains only
|
||
// inventory slots; item category, rarity, maximum upgrade count and legal
|
||
// placement range must all be derived from the runtime PVF.
|
||
type MonsterCardUpgradeResolution struct {
|
||
ItemID int64
|
||
PVFPath string
|
||
Rarity int
|
||
MaximumUpgrade byte
|
||
StackLimit int64
|
||
SlotStart int16
|
||
SlotEnd int16
|
||
GoldCost int64
|
||
CalculateConst int
|
||
}
|
||
|
||
// MonsterCardUpgradeResolver resolves one raw monster-card template through
|
||
// the active runtime PVF. A nil or incomplete result makes op603 fail closed.
|
||
type MonsterCardUpgradeResolver func(itemID int64) (MonsterCardUpgradeResolution, error)
|
||
|
||
// MonsterCardUpgradePolicy is the enchanter's own authority over a card
|
||
// upgrade, taken from the [monstercard exp] row for the character's current
|
||
// enchanter level in character/expertjob/enchanter.exj. SuccessRates is indexed
|
||
// by the target card's rarity and is a percentage; at level 1 the live table
|
||
// reads 100/70/60/50/0 and at level 11 every tier is 100. Experience is what
|
||
// one attempt feeds back into the enchanter job.
|
||
//
|
||
// A zero value means the enchanter config was unavailable, and the caller falls
|
||
// back to the rarity-gap estimate it used before this table was wired in.
|
||
type MonsterCardUpgradePolicy struct {
|
||
Known bool
|
||
Level int
|
||
SuccessRates [5]int
|
||
MinimumExperienceGain int
|
||
MaximumExperienceGain int
|
||
}
|
||
|
||
// AmplifyWeightedLevel is one runtime-PVF [amplification random value]
|
||
// level/weight pair carried by a specific Pure Gold material.
|
||
type AmplifyWeightedLevel struct {
|
||
Level byte
|
||
Weight int64
|
||
}
|
||
|
||
// AmplifyItemResolution is the dependency-neutral runtime-PVF result used by
|
||
// class1/op204 (purify/clear) and class1/op205 (invest/twist/Pure Gold).
|
||
// Option values follow etc/amplifyitem.etc: 1..4 are the four concrete
|
||
// amplification attributes and 5 means the client-selected [all] family.
|
||
// InitialValues is keyed by the final client amplification attribute 1..4.
|
||
type AmplifyItemResolution struct {
|
||
TargetKind string
|
||
TargetPVFPath string
|
||
TargetMinimumLevel int64
|
||
TargetRarity int64
|
||
EquipLevelConst int64
|
||
PurifyMaterialCount int64
|
||
ClearMaterialCount int64
|
||
InvestOption byte
|
||
InvestMaterialCount int64
|
||
ReinvestOption byte
|
||
ReinvestMaterialCount int64
|
||
PureGoldOption byte
|
||
PureGoldMaterialCount int64
|
||
InitialValues map[byte]uint16
|
||
PureGoldLevels []AmplifyWeightedLevel
|
||
MaterialPVFPath string
|
||
}
|
||
|
||
// AmplifyItemResolver resolves the real material and target templates through
|
||
// the active runtime PVF. A nil resolver means the mutation must fail closed.
|
||
type AmplifyItemResolver func(materialItemID int64, targetItemID int64) (AmplifyItemResolution, error)
|
||
|
||
// RandomOptionWeightedQuantity is one runtime-PVF random-option count roll.
|
||
type RandomOptionWeightedQuantity struct {
|
||
Quantity byte
|
||
Weight int64
|
||
}
|
||
|
||
// RandomOptionCandidate is one weighted, level-resolved magic-seal option.
|
||
// Type/Value1/Value2 map directly to the current NoPack raw item fields.
|
||
type RandomOptionCandidate struct {
|
||
Type byte
|
||
Value1 byte
|
||
Value2 byte
|
||
Weight int64
|
||
}
|
||
|
||
// RandomOptionGradeBand is one [postfix grade modification] row of
|
||
// etc/randomoption/randomizedoptionoverall2.etc. Grade 1..4 are the C/B/A/S
|
||
// seal grades, MinPct/MaxPct are the inclusive percentage window the grade
|
||
// covers inside the option's own value range, and Weight is the selection
|
||
// weight (the current PVF weights 330/520/100/50, i.e. out of 1000).
|
||
type RandomOptionGradeBand struct {
|
||
Grade int64
|
||
MinPct int64
|
||
MaxPct int64
|
||
Weight int64
|
||
}
|
||
|
||
// RandomOptionPercentWindow is one [different weight] row of
|
||
// etc/randomoption/randomizedoptionoverall2.etc: "rarity slot minPct maxPct",
|
||
// the percentile window the initial (解封) roll uses for one 1-based seal slot.
|
||
// The current PVF ships 2 1 0 25 / 2 2 10 30 / 2 3 30 55 for rare and
|
||
// 3 1 10 35 / 3 2 20 40 / 3 3 40 65 for unique, i.e. the third seal always rolls
|
||
// higher than the first.
|
||
type RandomOptionPercentWindow struct {
|
||
Slot int64
|
||
MinPct int64
|
||
MaxPct int64
|
||
}
|
||
|
||
// RandomOptionResolution is the complete runtime-PVF policy for one target.
|
||
// InitialGroups and ModifiedGroups contain the three equipment-specific option
|
||
// pools selected by optiongroupselection.etc.
|
||
type RandomOptionResolution struct {
|
||
TargetKind string
|
||
TargetPVFPath string
|
||
TargetEquipmentKey string
|
||
TargetMinimumLevel int64
|
||
TargetRarity int64
|
||
Eligible bool
|
||
QuantityWeights []RandomOptionWeightedQuantity
|
||
InitialGroups [][]RandomOptionCandidate
|
||
ModifiedGroups [][]RandomOptionCandidate
|
||
BreakSealGoldCost int64
|
||
ModificationGoldCost int64
|
||
// ResetGrades is the current [postfix grade modification] window table for
|
||
// the target rarity, one row per grade. Empty means op455 cannot re-roll
|
||
// this item's seal grades at all.
|
||
ResetGrades []RandomOptionGradeBand
|
||
// GradeUnmodifiable mirrors [unable to modify postfix grade] and
|
||
// GradeGradeless mirrors [gradeless postfix]; both list option ids the
|
||
// current PVF forbids re-rolling the seal grade of.
|
||
GradeUnmodifiable map[byte]struct{}
|
||
GradeGradeless map[byte]struct{}
|
||
// InitialSlotWindows is the current [different weight] table for the target
|
||
// rarity: the percentile window each 1-based seal slot rolls its initial
|
||
// value in (op401 解封).
|
||
InitialSlotWindows []RandomOptionPercentWindow
|
||
// ChangeSlotWindows mirrors [changed postfix] when the PVF ships it. The
|
||
// current PVF does not, so consumers fall back to InitialSlotWindows.
|
||
ChangeSlotWindows []RandomOptionPercentWindow
|
||
}
|
||
|
||
// RandomOptionResolver resolves the real target template through the active
|
||
// runtime PVF. Nil or incomplete results make the mutation fail closed.
|
||
type RandomOptionResolver func(targetItemID int64) (RandomOptionResolution, error)
|
||
|
||
// EquipmentRegenerationMaterial is one PVF-derived cost row for op407.
|
||
// AccountShared is derived from the server's fixed soul warehouse mapping;
|
||
// the request never supplies a material slot or item identity.
|
||
type EquipmentRegenerationMaterial struct {
|
||
ItemID int64
|
||
Count int64
|
||
AccountShared bool
|
||
}
|
||
|
||
// EquipmentRegenerationResolution is the fully selected, active-PVF result
|
||
// for one op407 equipment compound. The inventory owner remains authoritative
|
||
// for the source row and applies this result together with every material in
|
||
// one account+character asset transaction.
|
||
type EquipmentRegenerationResolution struct {
|
||
Eligible bool
|
||
SourceItemID int64
|
||
SourcePVFPath string
|
||
ResultItemID int64
|
||
ResultPVFPath string
|
||
ResultEquipmentType string
|
||
ResultRarity int64
|
||
ResultMinimumLevel int64
|
||
ResultDurability uint16
|
||
QualitySeed uint32
|
||
Materials []EquipmentRegenerationMaterial
|
||
CandidateCount int
|
||
}
|
||
|
||
// EquipmentRegenerationResolver resolves and selects a legal compound result
|
||
// from the active PVF. Nil or incomplete evidence makes op407 fail closed.
|
||
type EquipmentRegenerationResolver func(sourceItemID int64, mode uint16, part uint16) (EquipmentRegenerationResolution, error)
|
||
|
||
// UpgradeTicketRandomEntry is one weighted candidate of an [enchant random]
|
||
// ticket: the ticket scene picks exactly one entry by weight and jumps the
|
||
// equipment straight to that entry's level. Weight is out of 100000.
|
||
type UpgradeTicketRandomEntry struct {
|
||
TargetLevel int64
|
||
Weight int64
|
||
}
|
||
|
||
// EnchantRandomTicket is the runtime-PVF payload shared by every [enchant
|
||
// random] stackable. The op50 reinforcement ticket scene and the op438
|
||
// weapon-forging ticket family read the same child sections: er_enchant
|
||
// carries the (level, weight-out-of-100000) candidates, er_slot restricts the
|
||
// target part (0 any, 1 weapon, 2 armor, 3 accessory), er_grade whitelists
|
||
// target rarities (empty = any) and er_seal == 1 demands a sealed target (the
|
||
// common er_seal 0/2 carries no check).
|
||
type EnchantRandomTicket struct {
|
||
Entries []UpgradeTicketRandomEntry
|
||
SlotRestriction int64
|
||
Rarities []int64
|
||
SealRestriction int64
|
||
}
|
||
|
||
// UpgradeTicketResolution is the dependency-neutral runtime-PVF result passed
|
||
// from dnfbridge into the inventory domain for one op50 ticket-scene upgrade
|
||
// attempt. TicketMode is "reinforce" or "amplify" when the material template
|
||
// carries [equipment (amplify )?reinforcement ticket]; an empty TicketMode
|
||
// means the material is not an upgrade ticket and the request must stay on
|
||
// the pending normal-reinforcement path. SuccessWeight is the ticket's
|
||
// success chance out of 100000 (percent*1000). TicketRandom marks the
|
||
// [enchant random] multi-candidate family (e.g. 祝福强化券 2681908/2681909),
|
||
// whose candidates and target restrictions arrive in RandomTicket and are
|
||
// checked against TargetRarity plus the target row's seal flag.
|
||
type UpgradeTicketResolution struct {
|
||
TicketMode string
|
||
TicketRandom bool
|
||
RandomTicket EnchantRandomTicket
|
||
PortableAmplifier bool
|
||
PortableReinforcer bool
|
||
PortableMinimumLevel int64
|
||
PortableMaximumLevel int64
|
||
TargetMinimumLevel int64
|
||
TargetLevelMinimum int64
|
||
TargetLevelMaximum int64
|
||
TargetLevel int64
|
||
SuccessWeight int64
|
||
TargetUpgradeForbidden bool
|
||
TargetEquipmentType string
|
||
TargetRarity int64
|
||
TargetKind string
|
||
TicketPVFPath string
|
||
}
|
||
|
||
// UpgradeTicketResolver resolves the material and target templates through
|
||
// the active runtime PVF. A nil resolver means the ticket mutation must fail
|
||
// closed instead of trusting request or inventory-Extra metadata.
|
||
type UpgradeTicketResolver func(materialItemID int64, targetItemID int64) (UpgradeTicketResolution, error)
|
||
|
||
// UpgradeProtectionResolution is the active-PVF authority for the optional
|
||
// protection-ticket slot carried by op50. TicketMode is "reinforce" or
|
||
// "amplify". FailureRetainLevel is zero for the ordinary protection family
|
||
// and the explicit [action type] parameter for the [new ... protect
|
||
// equipment] family.
|
||
type UpgradeProtectionResolution struct {
|
||
TicketMode string
|
||
FailureRetainLevel int
|
||
TicketPVFPath string
|
||
}
|
||
|
||
// UpgradeProtectionResolver resolves the exact stackable template found in
|
||
// the request's optional ticket slot. An empty TicketMode is not a protection
|
||
// ticket and must fail closed without consuming it.
|
||
type UpgradeProtectionResolver func(ticketItemID int64) (UpgradeProtectionResolution, error)
|
||
|
||
// UpgradePolicyResolution carries the PVF-derived success/penalty parameters
|
||
// for one normal NPC upgrade attempt (non-ticket scene).
|
||
type UpgradePolicyResolution struct {
|
||
SuccessWeight int // out of 100000
|
||
PenaltyType int // 0=no change, 1=one-level downgrade, 2=value downgrade, 3=destroy
|
||
PenaltyValue int
|
||
GoldCost int64
|
||
MaterialItemID int
|
||
MaterialCount int
|
||
DestroyBonusItemID int
|
||
DestroyBonusCount int
|
||
// DestroyExtraRewards are additional rows granted alongside the
|
||
// destruction compensation: the disjointer.exj [chronicle set disjoint
|
||
// result] refund for 异界 equipment, the companion trace an amplified
|
||
// target also refunds, and this server's house rule of returning what the
|
||
// destroyed equipment would have yielded in the 分解机.
|
||
DestroyExtraRewards []UpgradeDestroyReward
|
||
NoticeLevel int // announcement threshold (-1 = disabled)
|
||
|
||
// GuildMedal marks a guild flag (guild medal) upgrade attempt, which is
|
||
// resolved from the native flag_upgrade_table instead of the equipment
|
||
// upgrade table. CurrentLevel carries the pre-attempt flag level so the
|
||
// caller can mirror it back without re-deriving it from the row payload.
|
||
GuildMedal bool
|
||
CurrentLevel int
|
||
}
|
||
|
||
// UpgradeDestroyReward is one item row granted when an upgrade destroys the
|
||
// target.
|
||
type UpgradeDestroyReward struct {
|
||
ItemID int
|
||
Count int
|
||
// BestEffort rows are dropped when the inventory has no room for them
|
||
// instead of failing the whole upgrade. The PVF-mandated compensation is
|
||
// never best-effort; the disassembly refund this server adds on top is,
|
||
// because losing a bonus row must not void the attempt itself.
|
||
BestEffort bool
|
||
}
|
||
|
||
// UpgradeRefundMailer delivers destruction refund rows the bag had no room
|
||
// for. It runs after the upgrade's asset transaction commits, because that
|
||
// transaction covers characters, inventory and equipment while mailboxes have
|
||
// their own unit of work, and the destroyed target may sit on the equipment
|
||
// page. The implementation owns its logging: a mail failure must not undo an
|
||
// upgrade that already committed.
|
||
type UpgradeRefundMailer func(ctx context.Context, characterID string, rows []UpgradeDestroyReward) error
|
||
|
||
// UpgradePolicyResolver resolves the PVF upgrade table row and target-dependent
|
||
// gold cost for the given mode, current upgrade level, and equipment template.
|
||
// amplifyType is the target's 红字 attribute (0 when it carries none); the
|
||
// destruction refund needs it because the disjointer's otherverse bonus is
|
||
// keyed off it. A nil resolver means guaranteed success (backward compatible
|
||
// with pre-PVF-table behavior).
|
||
type UpgradePolicyResolver func(mode string, currentLevel int, targetItemID int64, amplifyType int) (UpgradePolicyResolution, error)
|
||
|
||
// ForgingResolution carries the runtime-PVF policy for one weapon-forging
|
||
// attempt. SuccessWeight uses the native upgrade_separate.etc base of 10000.
|
||
// The current client stores the durable forging level in its 0x77 item row;
|
||
// no request-provided item metadata is authoritative here.
|
||
type ForgingResolution struct {
|
||
TargetKind string
|
||
TargetPVFPath string
|
||
TargetEquipmentType string
|
||
TargetDurability uint16
|
||
MaxLevel byte
|
||
TargetLevel byte
|
||
IndependentAttack int64
|
||
SuccessWeight int
|
||
MaterialItemID int64
|
||
MaterialCount int64
|
||
// TicketMode is "fixed" or "additional" when the request material
|
||
// carries [equipment separate reinforcement ticket]. An empty value keeps
|
||
// the ordinary strong-breath forging path. TicketApplyValue is the PVF
|
||
// source-level cap used by additional tickets.
|
||
TicketMode string
|
||
TicketApplyValue int64
|
||
TicketPVFPath string
|
||
// TicketRandom marks the second forging-coupon family: an [enchant random]
|
||
// stackable whose [action type] is `[random upgrade separate]` (e.g.
|
||
// +6锻造券 2683788). RandomTicket carries its er_enchant forging-level
|
||
// candidates and the er_slot / er_grade / er_seal target restrictions,
|
||
// which are checked against TargetRarity plus the target row's seal flag.
|
||
// The domain owns the candidate roll, so the resolver leaves TargetLevel
|
||
// and SuccessWeight unset and pre-computes the independent attack of every
|
||
// candidate level in RandomIndependentAttacks.
|
||
TicketRandom bool
|
||
RandomTicket EnchantRandomTicket
|
||
TargetRarity int64
|
||
RandomIndependentAttacks map[int64]int64
|
||
}
|
||
|
||
// ForgingResolver resolves the request material, target item and resulting
|
||
// forging level through the active runtime PVF. The material identity is
|
||
// required because op438 is shared by ordinary forging materials and
|
||
// [equipment separate reinforcement ticket] coupons.
|
||
type ForgingResolver func(materialItemID int64, targetItemID int64, currentLevel byte) (ForgingResolution, error)
|
||
|
||
// ChronicleRefineResolution is the runtime-PVF authorization for one op370
|
||
// aura application. The client derives the actual skill property from the
|
||
// imported aura script; the server persists only the canonical aura ID and
|
||
// the four branch-selector bytes carried by the current item row.
|
||
type ChronicleRefineResolution struct {
|
||
MaterialPVFPath string
|
||
ImportedScriptPVFPath string
|
||
AuraType byte
|
||
CanonicalAuraOptionID int32
|
||
Probabilities [2]byte
|
||
FailureFragmentItemID int64
|
||
FailureCubeCount int64
|
||
FailureCubeItem MagicBoxRewardItem
|
||
FailureFragmentItem MagicBoxRewardItem
|
||
TargetPVFPath string
|
||
TargetKind string
|
||
TargetEquipmentType string
|
||
TargetEquipmentTypeCode byte
|
||
TargetDurability uint16
|
||
Matched bool
|
||
}
|
||
|
||
// ChronicleRefineResolver validates the material import chain, target
|
||
// [character item check], and imported [check]/[skill] branch against the
|
||
// selected character. GrowType is the normalized low-nibble first class.
|
||
type ChronicleRefineResolver func(
|
||
materialItemID int64,
|
||
targetItemID int64,
|
||
characterJob byte,
|
||
firstGrowType byte,
|
||
optionNo byte,
|
||
) (ChronicleRefineResolution, error)
|
||
|
||
// MagicBoxRewardEntry is one weighted candidate inside a reward group.
|
||
type MagicBoxRewardEntry struct {
|
||
ItemID int64
|
||
Weight int64
|
||
Count int64
|
||
// Gold marks the official [upgradable legacy] pseudo reward whose PVF
|
||
// item id is zero. Count is the wallet amount; it must never be projected
|
||
// as a fabricated inventory item.
|
||
Gold bool
|
||
Option byte // Historical [booster info]/[avatar] fifth field.
|
||
}
|
||
|
||
// MagicBoxRewardGroup is one independently-drawn reward pool; each group
|
||
// yields DrawCount weighted picks per open (86JP RollBoosterRewards).
|
||
//
|
||
// Job/MinLevel/MaxLevel 来自 [booster info] 的 `[charactor]` 分组:那种分组是
|
||
// **按职业发放**的(例如豪华婚礼化妆箱每个职业一组时装),只有开启者职业匹配
|
||
// 且等级在区间内时该组才参与抽取。普通分组三者为零值,表示"人人可抽"。
|
||
type MagicBoxRewardGroup struct {
|
||
DrawCount int64
|
||
Entries []MagicBoxRewardEntry
|
||
Job string
|
||
MinLevel int64
|
||
MaxLevel int64
|
||
}
|
||
|
||
// MagicBoxResolution is the dependency-neutral runtime-PVF result passed from
|
||
// dnfbridge into the package domain for one magic-box open. Kind is "random"
|
||
// for weighted pools ([random upgradable legacy] RANDOMBOX groups and the
|
||
// [booster]/[cera booster]/[booster random] booster-info family), "package"
|
||
// for grant-all [cera package], "unsupported" for client-selected families,
|
||
// and "" when the template is not an openable box. MaterialItemID is the
|
||
// per-open material requirement (0 when the box opens free); the box itself
|
||
// is never reported as a material.
|
||
type MagicBoxResolution struct {
|
||
Kind string
|
||
Groups []MagicBoxRewardGroup
|
||
PackageItems []MagicBoxRewardEntry
|
||
MaterialItemID int64
|
||
MaterialCountPerUse int64
|
||
BoxPVFPath string
|
||
}
|
||
|
||
// MagicBoxResolver resolves one box template through the active runtime PVF.
|
||
// A nil resolver means the open must fail closed instead of trusting request
|
||
// or inventory-Extra metadata.
|
||
type MagicBoxResolver func(boxItemID int64) (MagicBoxResolution, error)
|
||
|
||
// MagicBoxRewardItem carries the grant metadata of one resolved reward
|
||
// template: inventory kind, stack limit (0 = unlimited), slot range, the seal
|
||
// flag derived from its PVF [attach type], its document path, and the absolute
|
||
// expiration resolved once from a relative PVF [usable period] at grant time.
|
||
type MagicBoxRewardItem struct {
|
||
ItemID int64
|
||
Kind string
|
||
TargetListType byte
|
||
EquipmentType string
|
||
StackLimit int64
|
||
SlotStart int16
|
||
SlotEnd int16
|
||
Seal bool
|
||
PVFPath string
|
||
ExpireAt time.Time
|
||
UsablePeriodDays int64
|
||
Durability uint16
|
||
// QualitySeed 是装备奖励的品级种子(raw+0x06)。
|
||
//
|
||
// **由解析方掷好再传进来,Owner 自己不掷**:本包的 Owner 侧一直是纯确定性的
|
||
// (同一份契约必然产生同一笔提交,事务里没有随机源),随机源只在 dnfbridge
|
||
// 那一层(抽奖掷骰、掉落掷品级都已经是那样做的)。0 表示不写,交给既有投影的
|
||
// 默认行为。
|
||
QualitySeed uint32
|
||
// DirectActivation marks a reward the runtime PVF defines as a premium
|
||
// contract. Such a reward must not occupy a bag cell: the owner reports its
|
||
// count back through MagicBoxResult.DirectActivations and the bridge extends
|
||
// the account entitlement instead, which is what the official service does.
|
||
DirectActivation bool
|
||
}
|
||
|
||
// MagicBoxRewardItemResolver resolves one reward template through the active
|
||
// runtime PVF for the durable grant step.
|
||
type MagicBoxRewardItemResolver func(itemID int64) (MagicBoxRewardItem, error)
|
||
|
||
// MagicBoxContractActivator extends the account entitlement for premium contract
|
||
// units a magic-box reward flow rolled without placing them in the bag. It runs
|
||
// inside the box owner's repository transaction, with the account record the
|
||
// owner is about to persist, so the entitlement and the box consumption commit
|
||
// together. Returning an error makes the owner place those units as ordinary
|
||
// rewards instead, which keeps the reward recoverable exactly as before.
|
||
type MagicBoxContractActivator func(account *dnfrepo.AccountRecord, units map[int64]int64, now time.Time) error
|
||
|
||
// RandomRewardItemOutcome is one weighted [chn random image percent] result.
|
||
// A zero Reward.ItemID is the PVF's no-item visual-effect outcome.
|
||
type RandomRewardItemOutcome struct {
|
||
Weight int64
|
||
Reward MagicBoxRewardItem
|
||
}
|
||
|
||
// RandomRewardItemResolution is the runtime-PVF authorization for a
|
||
// class-1/type251 shared-effect item with [stackable type] [random reward
|
||
// item]. The source is always consumed; one weighted outcome may add one
|
||
// repository-backed item.
|
||
type RandomRewardItemResolution struct {
|
||
SourceItemID int64
|
||
SourcePVFPath string
|
||
StackableType string
|
||
Outcomes []RandomRewardItemOutcome
|
||
}
|
||
|
||
// RandomRewardItemResolver keeps the random-reward table and reward template
|
||
// metadata in dnfbridge, rather than trusting mutable inventory Extra fields.
|
||
// A zero SourceItemID means the candidate is not this consumable family.
|
||
type RandomRewardItemResolver func(itemID int64) (RandomRewardItemResolution, error)
|
||
|
||
// PremiumContractResolution is the dependency-neutral runtime-PVF result for
|
||
// one contract item activation: the premium type and its duration in seconds,
|
||
// read from premiumlist_new.etc explicit [item]/[term] entries or a legacy
|
||
// type-level [target item]/[term] block when that type has no explicit items.
|
||
type PremiumContractResolution struct {
|
||
ItemID int64
|
||
PremiumType int64
|
||
DurationSeconds int64
|
||
}
|
||
|
||
// PremiumContractResolver resolves one candidate item through the active
|
||
// runtime PVF. A zero PremiumType means the item is not a contract item and
|
||
// the caller must fall back to the ordinary use-stackable path.
|
||
type PremiumContractResolver func(itemID int64) (PremiumContractResolution, error)
|
||
|
||
// ExperienceConsumableResolution is the runtime-PVF authority for one
|
||
// [waste] or [unlimited waste] item carrying [exp bonus rate] and
|
||
// [stat change duration]. The PVF rate is projected as an additive percentage:
|
||
// 2.0 means +200% (3x total). Reusable follows the PVF stackable type and is
|
||
// never inferred from an item name or client request.
|
||
type ExperienceConsumableResolution struct {
|
||
ItemID int64
|
||
BonusPercent int64
|
||
DurationSeconds int64
|
||
ExperienceSourceKey uint32
|
||
PVFPath string
|
||
Reusable bool
|
||
}
|
||
|
||
// ExperienceConsumableResolver returns a zero BonusPercent for ordinary
|
||
// stackables. Only current-PVF item documents may create an active effect.
|
||
type ExperienceConsumableResolver func(itemID int64) (ExperienceConsumableResolution, error)
|
||
|
||
// DirectExperienceConsumableResolution is the runtime-PVF contract for one
|
||
// immediate character-experience item. EffectType is one of the four native
|
||
// [increase status type] families and EffectValues preserves the exact PVF
|
||
// operands; names, client requests and mutable inventory metadata never select
|
||
// an experience amount.
|
||
type DirectExperienceConsumableResolution struct {
|
||
ItemID int64
|
||
PVFPath string
|
||
EffectType string
|
||
EffectValues []int64
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
}
|
||
|
||
// DirectExperienceConsumablePlan couples character EXP/level and the SP/TP
|
||
// ledger. The inventory owner applies this plan and the source decrement in a
|
||
// single CharacterSettlement transaction.
|
||
type DirectExperienceConsumablePlan struct {
|
||
Character dnfrepo.CharacterRecord
|
||
Skill dnfrepo.SkillRecord
|
||
CharacterFields []dnfrepo.CharacterField
|
||
SkillFields []dnfrepo.SkillField
|
||
Gain uint32
|
||
PreviousLevel int
|
||
NewLevel int
|
||
PreviousExperience uint32
|
||
NewExperience uint32
|
||
SPGain int
|
||
TPGain int
|
||
}
|
||
|
||
// DirectExperienceConsumablePlanner keeps runtime-PVF progression tables and
|
||
// level-stat projection in dnfbridge while the inventory owner retains the
|
||
// atomic write boundary.
|
||
type DirectExperienceConsumablePlanner func(
|
||
character dnfrepo.CharacterRecord,
|
||
skill dnfrepo.SkillRecord,
|
||
resolution DirectExperienceConsumableResolution,
|
||
updatedAt time.Time,
|
||
) (DirectExperienceConsumablePlan, error)
|
||
|
||
// StackableUseResolution is the current-PVF authorization for an ordinary
|
||
// type-44 stackable use. It exists separately from inventory Extra so legacy
|
||
// rows with a PVF path but missing stackable_type can still be validated
|
||
// without trusting client-supplied metadata.
|
||
type StackableUseResolution struct {
|
||
ItemID int64
|
||
PVFPath string
|
||
StackableType string
|
||
TPBook *TPBookResolution
|
||
PetFeedEffectDurationSeconds int64
|
||
PetFeedSkillChargePercent int64
|
||
PetFeedSatietyDelta int64
|
||
DirectExperience *DirectExperienceConsumableResolution
|
||
QuestCompletionTicket *QuestCompletionTicketResolution
|
||
StoryBook *StoryBookResolution
|
||
Transformation *TransformationUseResolution
|
||
Projectile *ProjectileUseResolution
|
||
UsePlace *StackableUsePlaceResolution
|
||
AvatarFittingRoomTicket *AvatarFittingRoomTicketResolution
|
||
CustomAbilityGroupConverter *CustomAbilityGroupConverterResolution
|
||
DungeonAdmissionTicket *DungeonAdmissionTicketResolution
|
||
FatigueRecovery int64
|
||
FatigueMaximumRemaining int64
|
||
// DailyUseLimit is the item's own 每天可用次数 cap: the PVF
|
||
// [total usable count] of ANY stackable consumable, not a fatigue-potion
|
||
// special case. A positive value means the shared per-character daily
|
||
// counter enforces it (inventory.Owner.ReserveDailyItemUse) and the used
|
||
// count is projected to the client as op541; zero means the item has no cap
|
||
// and must not cost an extra database read.
|
||
DailyUseLimit int64
|
||
// MaintainsCooltime 是 PVF `[cooltime maintenance]`(值非零)的语义:这个冷却要
|
||
// **跨会话保留**,由服务端落一份到期时间,并在窗口内拒绝再次使用(客户端的冷却门
|
||
// 只是进程内内存,小退重上就没了——现场 2026-09-17:1 天冷却的消耗品重上后又能用)。
|
||
// CooltimeSeconds 来自 `[cool time]`(毫秒换算),CooltimeGroup 来自
|
||
// `[cooltime group]`(同组共享一个窗口,0 = 只锁自己)。MaintainsCooltime 为 false
|
||
// 时后两个字段必须忽略:只有 [cool time] 的道具(10 秒冷却的红蓝药)保持纯客户端语义。
|
||
MaintainsCooltime bool
|
||
CooltimeSeconds int64
|
||
CooltimeGroup int64
|
||
RentalPoint *RentalPointGrantResolution
|
||
// TarotLottery 是 PVF `[stackable type]` = `[monster tarot card]` 的抽奖契约
|
||
// (金币花费 + 已抽中的那一项)。它的提交需要金币与道具同笔,见
|
||
// inventory.Owner.useTarotLotteryCard。
|
||
TarotLottery *TarotLotteryResolution
|
||
// ContainerUpgrade 是 PVF `[action type]` 为 `[upgrade inventory]` /
|
||
// `[upgrade cargo]` / `[upgrade account cargo]` 的扩容券契约:使用后把对应容器
|
||
// 推进一档(背包 +8 格、金库 +16 格、帐号金库下一档)。
|
||
//
|
||
// 2026-10-03 现场(测试x,char 11):这三种券使用后没效果。取证发现客户端其实
|
||
// **有发包**:op430 UPGRADE_INVENTORY ×11(body 就是那张券的槽位 0x42/0x44)、
|
||
// op408 UPGRADE_CARGO ×1,而服务端对这两个 opcode **完全静默**(没有任何
|
||
// handler/路由登记,DispatchUpper 对未注册 opcode 直接 return)。所以这三条
|
||
// action type 既没有 opcode 接线、解析器里也没有分支。
|
||
ContainerUpgrade *ContainerUpgradeResolution
|
||
// StatChangePotion 是 PVF `[stackable type] [expert town potion]` + **没有**
|
||
// `[action type]` + `[stat change duration]` 这一族的"城镇自施放增益药剂"契约
|
||
// (史诗Buff药剂 / 欧皇药水 / 史派克的欧气药水 / 小灯笼的大瓶·小瓶史诗Buff药剂 …)。
|
||
//
|
||
// 现场 2026-10-03(测试x = char 11):490002458 史诗Buff药剂 连点 15 次,
|
||
// op44 全部被 "[waste] provenance" 那道门静默拒绝(error 17)——既没消耗也没效果。
|
||
// 这一族的效果是**服务端量**(深渊派对史诗掉落率),所以服务端必须自己记窗口,
|
||
// 见 internal/modules/dnf/statbuff。
|
||
StatChangePotion *StatChangePotionResolution
|
||
}
|
||
|
||
// StatChangePotionResolution 是一张城镇增益药剂的解析结果。
|
||
type StatChangePotionResolution struct {
|
||
// EffectDurationSeconds 来自 PVF `[stat change duration]`(毫秒换算)。
|
||
// 它既是增益窗口,也正好等于 `[cool time]`(本族都是 600000ms = 10 分钟)。
|
||
EffectDurationSeconds int64
|
||
// EpicDropRatePercent 是"表里查不到条目"时的回退值:说明文字里写了"掉落率"
|
||
// 就取 200(2 倍),否则 0(= 服务端不改掉落)。
|
||
//
|
||
// 为什么需要回退:这一族的**权威数值**在两张 buff 表里(见下面的 BuffRolls),
|
||
// 但 490000413 这类 2016 年老活动券在表里没有条目,而它自己的 `[explain]` 明写
|
||
// "史诗道具掉落率增加2倍"。两条路都查不到 ⇒ 0,绝不猜。
|
||
EpicDropRatePercent int64
|
||
// BuffRolls 是**权威**来源:etc/variousbuffstackablelist.etc 给出"这件道具按权重
|
||
// 随机给哪个 buff",etc/variousbufflist.etc 给出该 buff 的
|
||
// `epic_rarity_item_drop_rate` 数值(+N%)。现场 2026-10-03 抄录(可用
|
||
// cmd/scratch/pvfmember 复核):
|
||
//
|
||
// 490002458 → buff 1136 权重 100,param 100(+100% = 2 倍)
|
||
// 490005358 → buff 1239 权重 100,param 10(+10%)
|
||
// 490005359 → buff 1240 权重 100,param 5(+5%)
|
||
// 10008219 → buff 1090/1091/1092 概率 60/30/10,param 100/150/200
|
||
//
|
||
// 它是**概率表**,所以只能服务端掷点 —— 客户端算不出自己拿到哪一档,
|
||
// 这就是"必须下发状态给客户端"的原因(见 class0/op174 投影)。
|
||
BuffRolls []StatChangePotionBuffRoll
|
||
// UpgradeKind 是这件药剂对**强化/增幅成功率**的作用对象:
|
||
// "reinforce"(强化)/ "amplify"(增幅)/ ""(不影响)。服主 2026-10-03:
|
||
// "看看增幅的秘密 强化秘药这类增加强化增幅成功率的道具 也一并修复了"。
|
||
//
|
||
// 判定来自 PVF 的 `[stackable type]`:
|
||
//
|
||
// `[item upgrad buff usual upgrade]` → reinforce(强化的奥秘)
|
||
// `[item upgrad buff amplify upgrade]` → amplify(永恒的增幅的秘密)
|
||
//
|
||
// 而 `[expert town potion]` 户籍的药剂(赛丽亚的幸运强化药剂 10008220、强化秘药)
|
||
// 走 UpgradeRolls / UpgradeBonusPercent。
|
||
UpgradeKind string
|
||
// UpgradeRolls 是"道具 → 强化成功率 buff"的**概率表**(权威来源,与 BuffRolls 同构):
|
||
//
|
||
// 10008220 → buff 1097/1098/1099 概率 60/30/10,param 10/20/30
|
||
//
|
||
// (buff 1097/1098/1099 的 [tooltip text] 就是"强化成功率提升 (下级/中级/上级)")。
|
||
UpgradeRolls []StatChangePotionWeightedRoll
|
||
// UpgradeBonusPercent 是没有概率表时的**固定加成**(百分点)。
|
||
//
|
||
// 为什么需要它:强化秘药、强化的奥秘、永恒的增幅的秘密这几件在 PVF 里**根本没有数字**
|
||
// —— 两张 buff 表里都没有它们的条目,说明文字也只写"可以提升强化成功率"。
|
||
// 所以只能由服务端给一个常量,见 dnfbridge.currentUpgradeRatePotionDefaultBonusPercent
|
||
// (取值 10,依据是同一份 PVF 里另外两件写明了数字的道具:crystallight_10007343
|
||
// "强化成功率增加10%,持续30秒"、fortuneaura_success "增加10%的成功率,持续60分钟",
|
||
// 以及"下级"档本身就是 10)。
|
||
UpgradeBonusPercent int64
|
||
// DungeonClearExpRolls 是"道具 → 地下城通关经验 buff"的概率表(同样来自 PVF):
|
||
//
|
||
// 10008222(赛丽亚的幸运经验药剂)→ buff 1103/1104/1105 概率 30/50/20,param 50/100/200
|
||
//
|
||
// 通关经验是**服务端发的**,所以这一族必须服务端算(与掉落率同构)。
|
||
DungeonClearExpRolls []StatChangePotionWeightedRoll
|
||
// ClientBuffFamily / ClientBuffRolls 是"客户端自己施加"的那一族(服务端只负责把掷到的
|
||
// buff 注册给客户端,客户端按同一份 buff 表施加效果):
|
||
//
|
||
// 10008221(赛丽亚的幸运城镇药剂)→ buff 1100/1101/1102,`village_move_speed` 15/30/50
|
||
// 10008223(赛丽亚的幸运属性药剂)→ buff 1106/1107/1108,`move_speed` 进图属性三档
|
||
//
|
||
// ClientBuffFamily 就是 buff 的 effect name(窗口按 family 分开存,互不覆盖)。
|
||
ClientBuffFamily string
|
||
ClientBuffRolls []StatChangePotionWeightedRoll
|
||
}
|
||
|
||
// StatChangePotionBuffRoll 是一条"道具 → buff"的候选:buff id + 权重 + 该 buff 的
|
||
// 掉落率加成百分比(取自 buff 定义表的 effect param)。
|
||
type StatChangePotionBuffRoll struct {
|
||
BuffID int64
|
||
Weight int64
|
||
BonusPercent int64
|
||
}
|
||
|
||
// StatChangePotionWeightedRoll 是同族药剂在**某一种服务端效果**上的候选:
|
||
// buff id + 权重 + 该 buff 的 param(掉落率百分比 / 成功率百分点 / 通关经验百分比)。
|
||
//
|
||
// 强化成功率、通关经验、客户端自施放那几族共用它 —— 形状完全一样,分成多个类型只会
|
||
// 让解析与掷点各写一遍。
|
||
type StatChangePotionWeightedRoll struct {
|
||
BuffID int64
|
||
Weight int64
|
||
BonusPercent int64
|
||
}
|
||
|
||
// ContainerUpgradeKind 是扩容券作用的容器。
|
||
type ContainerUpgradeKind string
|
||
|
||
const (
|
||
// ContainerUpgradeInventory 对应 PVF `[action type] [upgrade inventory]`:
|
||
// 物品栏扩容券,每张 +8 格,上限 24(= 3 张)。
|
||
ContainerUpgradeInventory ContainerUpgradeKind = "upgrade_inventory"
|
||
// ContainerUpgradeCargo 对应 `[upgrade cargo]`:金库(个人仓库 list 2)扩容券。
|
||
ContainerUpgradeCargo ContainerUpgradeKind = "upgrade_cargo"
|
||
// ContainerUpgradeAccountCargo 对应 `[upgrade account cargo]`:帐号金库升级工具,
|
||
// 按 accountCargoTiers 推进一档。
|
||
ContainerUpgradeAccountCargo ContainerUpgradeKind = "upgrade_account_cargo"
|
||
)
|
||
|
||
// ContainerUpgradeResolution 是一张扩容券的解析结果。
|
||
type ContainerUpgradeResolution struct {
|
||
Kind ContainerUpgradeKind
|
||
// ItemID 是这张券自己(用于日志与校验)。
|
||
ItemID int64
|
||
// SlotIndex 是客户端声明的券所在槽位(op430/op408 的 2 字节 body 就是它)。
|
||
SlotIndex int16
|
||
// ListType 是券所在的容器(当前客户端都发 0 = 背包)。
|
||
ListType byte
|
||
}
|
||
|
||
// RentalPointGrantResolution is the active-PVF contract for a [waste] item
|
||
// whose [action type] is [rent point]: using it charges the account's rental
|
||
// wallet (the 幸运星 balance) by Grant, clamped to the rental system's
|
||
// [limit point]. The wallet itself stays owned by the rental module.
|
||
type RentalPointGrantResolution struct {
|
||
Grant uint32
|
||
Limit uint32
|
||
}
|
||
|
||
// DungeonAdmissionTicketResolution is the active-PVF contract for a waste
|
||
// item that expands one or more repository-owned admission policies in the
|
||
// current Beijing service day. The inventory owner commits source decrement
|
||
// and all character/account allowances in one settlement transaction.
|
||
type DungeonAdmissionTicketResolution struct {
|
||
ActionType string
|
||
ActionPacket uint32
|
||
TargetDungeonID uint32
|
||
Grants []dnfdungeonadmission.AllowanceGrant
|
||
UseLimit dnfdungeonadmission.AllowanceUseLimit
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
VillageAllowed bool
|
||
SeriaRoomAllowed bool
|
||
}
|
||
|
||
// StackableUsePlaceResolution carries the active PVF's explicit scene
|
||
// restriction for an ordinary [waste] action. It lets the inventory owner
|
||
// authorize old inventory rows by server-owned item identity while still
|
||
// rejecting a dungeon-only consumable outside its documented dungeon.
|
||
type StackableUsePlaceResolution struct {
|
||
VillageAllowed bool
|
||
DungeonAllowed bool
|
||
SeriaRoomAllowed bool
|
||
ActionUsableDungeonIDs []int64
|
||
LimitDungeonIDs []int64
|
||
}
|
||
|
||
// CustomAbilityGroupConverterResolution authorizes the current PVF's
|
||
// [custom ability set group] action. op44 only opens the native individual
|
||
// custom-ability window; op1340 later owns the option change and source debit.
|
||
type CustomAbilityGroupConverterResolution struct {
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
}
|
||
|
||
// AvatarFittingRoomTicketResolution authorizes the current PVF's
|
||
// [avatar fitting room ticket] action. op44 only opens the fitting-room UI;
|
||
// the ticket is deliberately consumed later by op1488, once the client has
|
||
// submitted the complete eight-part change request.
|
||
type AvatarFittingRoomTicketResolution struct {
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
VillageAllowed bool
|
||
SeriaAllowed bool
|
||
}
|
||
|
||
// TransformationUseResolution is the complete active-PVF authorization for
|
||
// one native transformation consumable. The server validates the current
|
||
// location before consuming the stack; the successful op44 response then lets
|
||
// the current client execute the item's own PVF action (disguise, riding or
|
||
// ridable-item) instead of fabricating a second transformation packet.
|
||
type TransformationUseResolution struct {
|
||
ActionType string
|
||
FormKinds []string
|
||
FormIDs []int64
|
||
DurationSeconds int64
|
||
VillageAllowed bool
|
||
DungeonAllowed bool
|
||
SeriaRoomAllowed bool
|
||
DisallowAssault bool
|
||
ActionUsableDungeonIDs []int64
|
||
LimitDungeonIDs []int64
|
||
}
|
||
|
||
// ProjectileUseResolution is the complete active-PVF contract for one
|
||
// dungeon throw/set item. The server owns location validation and durable
|
||
// consumption; the successful native op44 acknowledgement invokes the
|
||
// current client's own projectile/passive-object action.
|
||
type ProjectileUseResolution struct {
|
||
StackableType string
|
||
EffectID int64
|
||
IntData []int64
|
||
ParticlePaths []string
|
||
DungeonAllowed bool
|
||
ActionUsableDungeonIDs []int64
|
||
LimitDungeonIDs []int64
|
||
}
|
||
|
||
// StoryBookResolution is the exact current-PVF [story book] contract carried
|
||
// by one consumable. The client does not choose the quest ID independently;
|
||
// it is read only from the item's action values.
|
||
type StoryBookResolution struct {
|
||
ActionType string
|
||
QuestID int64
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
}
|
||
|
||
// StoryBookPlan is the quest mutation prepared from the active quest PVF.
|
||
// Inventory owns the final transaction so accepting the story and consuming
|
||
// the book cannot diverge.
|
||
type StoryBookPlan struct {
|
||
Quest dnfrepo.QuestRecord
|
||
QuestFields []dnfrepo.QuestField
|
||
QuestID int64
|
||
InitTrigger uint32
|
||
}
|
||
|
||
type StoryBookPlanner func(
|
||
character dnfrepo.CharacterRecord,
|
||
quest dnfrepo.QuestRecord,
|
||
book StoryBookResolution,
|
||
acceptedAt time.Time,
|
||
) (StoryBookPlan, error)
|
||
|
||
// SkillPointBookKind identifies the three legacy [etc] book effects whose
|
||
// current PVF documents have no action type.
|
||
type SkillPointBookKind byte
|
||
|
||
const (
|
||
SkillPointBookUnknown SkillPointBookKind = iota
|
||
SkillPointBookAddSP
|
||
SkillPointBookAddTP
|
||
SkillPointBookResetTP
|
||
)
|
||
|
||
// TPBookResolution is retained as the transport-neutral name used by the
|
||
// existing inventory boundary, but now models every current-PVF SP/TP book.
|
||
// Delta is authoritative only for AddSP/AddTP; ResetTP refunds purchased TP
|
||
// skill levels to their active-PVF baseline.
|
||
type TPBookResolution struct {
|
||
Kind SkillPointBookKind
|
||
Delta int64
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
}
|
||
|
||
// StackableUseResolver resolves one candidate ordinary consumable through the
|
||
// active runtime PVF. A zero ItemID means the item is not an authorized
|
||
// ordinary consumable.
|
||
type StackableUseResolver func(itemID int64) (StackableUseResolution, error)
|
||
|
||
// QuestCompletionTicketResolution is the complete runtime-PVF contract for a
|
||
// quest-completion stackable. ActionValues are the integer operands stored in
|
||
// the item's [action type] section (for example the exact quest IDs carried by
|
||
// [any quest clear]). Level bounds come from the same item document.
|
||
type QuestCompletionTicketResolution struct {
|
||
ActionType string
|
||
ActionValues []int64
|
||
MinimumLevel int64
|
||
MaximumLevel int64
|
||
VillageOnly bool
|
||
PreserveSideQuests bool
|
||
PreserveHellPartyUnlocks bool
|
||
}
|
||
|
||
// QuestCompletionTitleGrant is one title-book item unlocked by a completion
|
||
// ticket. The achievement family stores its state in Quest.Progress and the
|
||
// physical title in inventory list 100, so the plan carries the resolved title
|
||
// identity for the inventory owner to insert inside the same settlement.
|
||
type QuestCompletionTitleGrant struct {
|
||
QuestID int64
|
||
ItemID int32
|
||
Category int32
|
||
BookIndex int32
|
||
}
|
||
|
||
// QuestCompletionTicketPlan is a mutation plan produced from the current PVF
|
||
// quest catalog. The inventory owner applies it with the source-item decrement
|
||
// inside one CharacterSettlement transaction.
|
||
type QuestCompletionTicketPlan struct {
|
||
Quest dnfrepo.QuestRecord
|
||
Character dnfrepo.CharacterRecord
|
||
QuestFields []dnfrepo.QuestField
|
||
CharacterFields []dnfrepo.CharacterField
|
||
CompletedQuestIDs []int64
|
||
TitleGrants []QuestCompletionTitleGrant
|
||
ProfessionChanged bool
|
||
}
|
||
|
||
// QuestCompletionTicketPlanner keeps quest-catalog interpretation outside the
|
||
// inventory package while leaving the final inventory/quest/character commit
|
||
// under one repository transaction.
|
||
type QuestCompletionTicketPlanner func(
|
||
character dnfrepo.CharacterRecord,
|
||
quest dnfrepo.QuestRecord,
|
||
ticket QuestCompletionTicketResolution,
|
||
completedAt time.Time,
|
||
) (QuestCompletionTicketPlan, error)
|
||
|
||
// ChronicleGrowthResolution is the current-PVF policy for one op271
|
||
// equipment-level emancipation request. Client item IDs and slots remain
|
||
// lookup keys that the inventory owner revalidates inside its transaction.
|
||
type ChronicleGrowthResolution struct {
|
||
TicketItemID int64
|
||
MaterialItemID int64
|
||
UpgradeLevel int
|
||
TargetMinimumLevel int
|
||
ConditionMinimum int
|
||
ConditionMaximum int
|
||
TargetRarity int64
|
||
EquipmentType string
|
||
AllowedRarities []int64
|
||
Probability []ChronicleGrowthProbability
|
||
CostPolicy ChronicleGrowthCostPolicy
|
||
TicketPVFPath string
|
||
Conversion *ChronicleEquipmentConversionResolution
|
||
}
|
||
|
||
// ChronicleEquipmentConversionResolution is the current-PVF authority for
|
||
// the equipment [emancipate] branch of op271. The source equipment owns the
|
||
// complete material/output recipe; the ticket only selects the matching type
|
||
// and its retention limits.
|
||
type ChronicleEquipmentConversionResolution struct {
|
||
TicketType int64
|
||
PreserveAttributes bool
|
||
Inputs []ChronicleEquipmentConversionMaterial
|
||
OutputItemID int64
|
||
OutputPVFPath string
|
||
OutputEquipmentType string
|
||
OutputRarity int64
|
||
OutputGrade int64
|
||
OutputMinimumLevel int64
|
||
OutputDurability uint16
|
||
OutputQualitySeed uint32
|
||
BindOutput bool
|
||
UpgradeMaximum int
|
||
AmplifyMaximum int
|
||
ForgingMaximum int
|
||
}
|
||
|
||
type ChronicleEquipmentConversionMaterial struct {
|
||
ItemID int64
|
||
Count int64
|
||
}
|
||
|
||
type ChronicleGrowthProbability struct {
|
||
MaximumLevel int
|
||
Weight int64
|
||
}
|
||
|
||
type ChronicleGrowthWeightSegment struct {
|
||
Minimum int
|
||
Maximum int
|
||
MinimumWeight float64
|
||
MaximumWeight float64
|
||
}
|
||
|
||
type ChronicleGrowthCostPolicy struct {
|
||
Rarity int64
|
||
MaterialItemID int64
|
||
BaseCount int64
|
||
Multiplier float64
|
||
PartWeights map[string]float64
|
||
LevelWeights []ChronicleGrowthWeightSegment
|
||
UpgradeWeights []ChronicleGrowthWeightSegment
|
||
AmplifyWeights []ChronicleGrowthWeightSegment
|
||
ForgingWeights []ChronicleGrowthWeightSegment
|
||
}
|
||
|
||
type ChronicleGrowthResolver func(ticketItemID int64, targetItemID int64) (ChronicleGrowthResolution, error)
|
||
|
||
// DamageFontExpirationMode describes the expiration contract carried by a
|
||
// runtime-PVF [add damage font skin] item.
|
||
type DamageFontExpirationMode byte
|
||
|
||
const (
|
||
DamageFontExpirationUnknown DamageFontExpirationMode = iota
|
||
DamageFontExpirationPeriod
|
||
DamageFontExpirationFixed
|
||
DamageFontExpirationUnlimited
|
||
)
|
||
|
||
// DamageFontResolution is the runtime-PVF authority for one damage-font item.
|
||
// A zero FontIndex or invalid resolution must not consume the source item.
|
||
type DamageFontResolution struct {
|
||
Valid bool
|
||
PVFPath string
|
||
ActionType string
|
||
FontIndex uint16
|
||
ExpirationMode DamageFontExpirationMode
|
||
PeriodDays int64
|
||
FixedExpiration time.Time
|
||
}
|
||
|
||
// DamageFontResolver resolves one inventory source through the active PVF.
|
||
type DamageFontResolver func(itemID int64) (DamageFontResolution, error)
|
||
|
||
// RepairCostEvidence is the dependency-neutral runtime-PVF repair evidence
|
||
// for one equipment item, matching the 86JP EquipmentRepairPriceProvider
|
||
// inputs. MaxDurability <= 0 means the item is not repairable. The rates come
|
||
// from equipment/pricetable.tbl ([repair cost], [quick repair cost rate]) and
|
||
// etc/upgrade.etc [repair cost rate by upgrade level]; EquipmentType is the
|
||
// normalized PVF [equipment type] token used for the repair-all eligibility
|
||
// filter.
|
||
type RepairCostEvidence struct {
|
||
EquipmentType string
|
||
MaxDurability int64
|
||
RepairPrice int64
|
||
Grade int64
|
||
RepairCostRate float64
|
||
QuickRepairRate float64
|
||
UpgradeRates []float64
|
||
}
|
||
|
||
// RepairCostResolver resolves one candidate equipment item through the active
|
||
// runtime PVF. A nil resolver means the repair cost mutation must fail closed
|
||
// instead of trusting request or inventory-Extra metadata.
|
||
type RepairCostResolver func(itemID int64) (RepairCostEvidence, error)
|
||
|
||
// MailboxItemResolution is the current-PVF evidence used by the exact
|
||
// NoPack mailbox flow. Price is the item's [price] value; Kind and
|
||
// EquipmentType preserve the PVF type needed to frame a mailbox attachment
|
||
// without treating mutable inventory Extra data as the type authority.
|
||
// ListType plus SlotStart/SlotEnd and StackLimit are used when an attachment
|
||
// is claimed: they put the item in its real inventory owner/page rather than
|
||
// treating the op13 main-list expansion marker as a physical slot count.
|
||
// PreferQuickSlots is derived only from the authoritative PVF stackable type
|
||
// and never from client input, mailbox metadata, or an explicit player
|
||
// inventory move.
|
||
type MailboxItemResolution struct {
|
||
Price int64
|
||
PVFPath string
|
||
Kind string
|
||
EquipmentType string
|
||
StackableType string
|
||
Durability uint16
|
||
ListType byte
|
||
SlotStart int16
|
||
SlotEnd int16
|
||
StackLimit int64
|
||
PreferQuickSlots bool
|
||
// EpicBookEquipmentID is populated only when the active Epic Book catalog
|
||
// maps this exact attachment item to an equipment balance. Such a fragment
|
||
// is account ledger currency and must never be inserted into an inventory
|
||
// material slot. EpicBookFragmentID is retained for the native op1149 row.
|
||
EpicBookEquipmentID uint32
|
||
EpicBookFragmentID uint32
|
||
}
|
||
|
||
// MailboxItemResolver resolves each durable attachment template through the
|
||
// active runtime PVF. A nil resolver makes attachment sends fail closed.
|
||
type MailboxItemResolver func(itemID uint32) (MailboxItemResolution, error)
|
||
|
||
// PartyState 保存当前已经验证的单人队伍会话字段。
|
||
// 它只属于 game 连接临时态,真正多人队伍 owner 接入前不做持久化。
|
||
type PartyState struct {
|
||
PartyID int
|
||
IsLeader bool
|
||
UserID uint16
|
||
UserState byte
|
||
Members []PartyMemberState
|
||
RequestPrefix0 byte
|
||
RequestPrefix1 byte
|
||
NameBytes []byte
|
||
MemberSelectCode byte
|
||
MaxMembers byte
|
||
SelectionID uint32
|
||
SelectionCode byte
|
||
SelectionValue uint16
|
||
RecruitFlag byte
|
||
TargetMode byte
|
||
TargetDungeonID uint16
|
||
ReserveLeaveFlag byte
|
||
}
|
||
|
||
// PartyMemberState 是已验证队伍包里能安全写出的队员槽位状态。
|
||
type PartyMemberState struct {
|
||
UserID uint16
|
||
// Slot is the native fixed party position carried by class0/op9. It is
|
||
// independent from the Members slice index: op335 may move a member into
|
||
// an empty slot, and op121 changes leadership without renumbering members.
|
||
Slot byte
|
||
UserState byte
|
||
HPPercent byte
|
||
MPPercent byte
|
||
}
|
||
|
||
// Result 表示模块化分流后的处理结果。
|
||
// ResponseAllowed=false 表示证据不足或被阻断,bridge 不能伪造成功 ACK。
|
||
type Result struct {
|
||
Decision Decision
|
||
Handled bool
|
||
ResponseAllowed bool
|
||
Operation string
|
||
Reason string
|
||
// DailyUseStates reports per-item daily use counters that changed during this
|
||
// command. The bridge projects them to the client's own 每日可用次数 state
|
||
// (op541, class 1): the item tooltip renders "每天可用次数 : 剩余/上限" from
|
||
// that state, and nothing else feeds it -- the op14 item row does not carry
|
||
// it at all (reverse engineering 2026-09-15: the client's only writer is
|
||
// sub_01F20590, reached solely from the op541 handler at RVA 0x0197B2B0).
|
||
// EffectItemInfo 请 bridge 在 ACK 与物品行刷新之后,补发客户端自己的
|
||
// effect-item 记录(class0/op174:"load effect item info",
|
||
// body = {u8 1, u32 itemID, u32 remainingSeconds})。
|
||
//
|
||
// ⚠ 语义是**恢复**,不是"使用":首次使用时客户端已经按 op44 在本地登记了这个
|
||
// effect item(并打出"[道具]的效果时间还剩N"那一行),服务端再发 op174 会让它登记
|
||
// 第二次 —— 服主 2026-10-03 看到的"提示出现两次"就是这么来的(他自己给出了决定性
|
||
// 线索:"药剂刚能用那一版(服务端还没发这些包)提示只有一行")。同一约定在
|
||
// experience_consumable_projection.go 里早就写明了:
|
||
//
|
||
// "First use is handled locally by op44; op174 recreates the same item-owned
|
||
// effect with its durable remaining duration."
|
||
//
|
||
// 所以**所有命令的使用路径都必须让它保持 nil**;目前只有登录/重登的恢复路径会用
|
||
// op174,而那条路走的是 bridge 自己的直发(sendCurrentStatChangePotionEffectItemInfo
|
||
// / sendCurrentExperienceConsumableBuffProjection),不经过本字段。这个字段留着是
|
||
// 作为"恢复类投影"的通用出口,并被测试用 `== nil` 钉住(防止有人再把它接到使用路径上)。
|
||
//
|
||
// CharacterAddBuff 请 bridge 补发客户端自己的 buff 注册通知(class0/op487
|
||
// CHARACTER_ADD_BUFF,body = {u8 1, i32 buffId, i32 0, i32 0, i32 0})——这才是
|
||
// **buff 栏图标**的来源。
|
||
//
|
||
// 现场 2026-10-03:只发 op174 时服主看到的是"[史诗Buff药剂]的效果时间还剩10分钟"
|
||
// (其实那已经是客户端本地那一次 + op174 触发的第二次),图标没有出现。协议**形状**
|
||
// 参照机器上的 C# 参考服务端 A21(`EpicBuffPotionDefinition.BuffId = 1136` +
|
||
// `SpecialDungeonNotificationBuilder.BuildCharacterAddBuff` 的 17 字节记录体),
|
||
// 但**协议号必须在我们的客户端里定标**:A21 的 NOTIPACKET 基址是 486,本客户端是
|
||
// 485(见 stat_change_potion_projection.go 顶部注释与 tools/_gb_noti_table_re.py)。
|
||
CharacterAddBuff *CharacterAddBuffProjection
|
||
EffectItemInfo *EffectItemInfoProjection
|
||
DailyUseStates []DailyUseState
|
||
// ItemCooltimeStates reports the durable item cooldown windows a command
|
||
// started (PVF `[cool time]` + `[cooltime maintenance]`). Nothing is sent
|
||
// for them: the client reads the same PVF and starts its own local cooldown
|
||
// when it consumes the item. They exist so the bridge can log the window and
|
||
// so a refused reuse is observable; the restore path after a relog is the
|
||
// item-state notification 172 list (item_cooltime_projection.go).
|
||
ItemCooltimeStates []ItemCooltimeState
|
||
UpperResponses []UpperResponse
|
||
// AreaBroadcastResponses are current-client notifications delivered to
|
||
// other players who already share the requester's concrete town area. The
|
||
// requester receives its own feedback through UpperResponses.
|
||
AreaBroadcastResponses []UpperResponse
|
||
// MailboxAlarmRecipientID asks the bridge to deliver the exact class0/0x63
|
||
// mailbox alarm to this already-online character after the sender's ACK and
|
||
// its local post-actions are fully written. Zero means no cross-session send.
|
||
MailboxAlarmRecipientID uint16
|
||
// ContractActivations carries premium contract units (item id -> count) that
|
||
// a reward flow rolled but deliberately did not place in the bag. The bridge
|
||
// extends the account entitlement for them instead of projecting item rows.
|
||
ContractActivations map[int64]int64
|
||
// ItemSlotRefreshes names only the durable item rows changed by this
|
||
// command. The bridge reloads and projects these slots through the current
|
||
// EXE's incremental item-update protocol after every ACK succeeds.
|
||
ItemSlotRefreshes []ItemSlotRefresh
|
||
// ItemListRebuilds 请 bridge 在 ItemSlotRefreshes 之后,对这些 list 各发一次
|
||
// **整表重建**(op13)。
|
||
//
|
||
// 用在"原地改行"的场景:这个旧客户端的增量 op14 对**同槽同 item id、只有行内
|
||
// 某个字段变化**的更新不重绘。怪物卡片升级到满级时正好是这种情况——前几级每次
|
||
// 都换槽位,客户端看到的是"删一个+加一个"所以重画了;最后一级留在原槽,只有
|
||
// 升级字节 03->04,界面就一直停在旧值,要重新选角色才正常。
|
||
//
|
||
// 同类问题此前在 list 7(宠物栏,见 sendSelectedPetInventoryListRefresh)和
|
||
// list 38(公会勋章/守护珠合成)上都出现过,都是靠整表重建解决的。
|
||
ItemListRebuilds []byte
|
||
// EquipmentSlotRefreshes names worn equipment rows changed by this command.
|
||
// The bridge sends each slot as its own class-0 op14/list3 packet after the
|
||
// ACK; batching or replaying the complete equipped table is unsafe while the
|
||
// current client owns live equipment objects.
|
||
EquipmentSlotRefreshes []int16
|
||
// PostResponseItemSlotClears emits synthetic empty op14 rows after the
|
||
// command ACK and before ItemSlotRefreshes. Equipment replacement needs
|
||
// this narrow invalidation phase so the client drops the old item core
|
||
// before receiving the authoritative converted row.
|
||
PostResponseItemSlotClears []ItemSlotRefresh
|
||
// PostItemSlotRefreshResponses are current-client notifications whose
|
||
// handlers must run only after the incremental item rows above are visible.
|
||
// Mailbox extraction uses this narrow phase for class0/op98: sending the
|
||
// clear-first op97 page while op95 still owns the selected attachment crashes
|
||
// the current client, while sending op98 before op14 leaves a stale item row.
|
||
PostItemSlotRefreshResponses []UpperResponse
|
||
// op271's current result handler reloads its target while consuming the ACK,
|
||
// so the target op14 must precede the class1 response.
|
||
PreResponseItemSlotRefreshes []ItemSlotRefresh
|
||
// CharacterGoldAfter requests the native list-0 slot-0 wallet projection.
|
||
// It is sent after ordinary item rows but before post-item callbacks such as
|
||
// mailbox op98; slot 0 is not an inventory row and must never be represented
|
||
// as an ItemSlotRefresh.
|
||
CharacterGoldAfter *int64
|
||
PostActions []PostAction
|
||
SeriaLuck *SeriaLuckProjection
|
||
CeraWallet *CeraWalletProjection
|
||
// CeraSpent/CeraAfter/CeraSpentNote are the audit hooks for a command that
|
||
// debits account Cera directly instead of going through the Cera-shop
|
||
// checkout. 金库升级(cargo owner)是当前唯一一条这样的路径:它的成功回包里
|
||
// 只带一个"扣完之后的余额",而点券消费记录需要"扣了多少、为什么扣",
|
||
// 所以由模块自己把这两项填出来,bridge 统一记账(见 game_aligned.go)。
|
||
// CeraSpent 为 0 表示本条命令没有点券消费。
|
||
CeraSpent int64
|
||
CeraAfter int64
|
||
CeraSpentNote string
|
||
// EpicBookBalances contains committed absolute balances. The bridge sends
|
||
// their native class-0 op1149 refresh after the command ACK and before any
|
||
// mailbox row removal callback.
|
||
EpicBookBalances []EpicBookBalanceProjection
|
||
// CustomAbilityGroupConverter is set only after op44 has proved the source
|
||
// row without consuming it. dnfbridge arms its op1340 continuation after
|
||
// the successful op44 response has been written.
|
||
CustomAbilityGroupConverter *CustomAbilityGroupConverterProjection
|
||
}
|
||
|
||
type CustomAbilityGroupConverterProjection struct {
|
||
CharacterID uint16
|
||
SourceListType byte
|
||
SourceSlotIndex int16
|
||
SourceInstance int32
|
||
SourceItemID int64
|
||
}
|
||
|
||
type SeriaLuckProjection struct {
|
||
Before int64
|
||
After int64
|
||
}
|
||
|
||
// CeraWalletProjection is the complete account wallet required by the
|
||
// current client's class0/op53 reader. Domain owners return durable values;
|
||
// dnfbridge remains responsible for the wire encoding and packet order.
|
||
type CeraWalletProjection struct {
|
||
Cera int64
|
||
TokenCera int64
|
||
HappyTokenCera int64
|
||
}
|
||
|
||
type EpicBookBalanceProjection struct {
|
||
FragmentID uint32
|
||
Balance uint32
|
||
}
|
||
|
||
type ItemSlotRefresh struct {
|
||
ListType byte
|
||
SlotIndex int16
|
||
}
|
||
|
||
// PostAction describes a bridge-owned state refresh that must run only after
|
||
// every durable owner mutation and protocol ACK in UpperResponses succeeds.
|
||
// Business modules select the action; the bridge remains responsible for the
|
||
// current EXE-specific packet body and session context.
|
||
type PostAction string
|
||
|
||
const (
|
||
PostActionRefreshSelectedActorAppearance PostAction = "refresh_selected_actor_appearance"
|
||
// The current EXE's class-0 op342 updates occupied equipment rows on an
|
||
// already-created town actor. It complements the mode0 redraw above:
|
||
// mode0 removes stale worn parts, whereas op342 attaches newly equipped
|
||
// equipment without creating a new actor generation.
|
||
PostActionRefreshSelectedActorExpandedEquipment PostAction = "refresh_selected_actor_expanded_equipment"
|
||
PostActionRefreshSelectedItemContainers PostAction = "refresh_selected_item_containers"
|
||
// 宠物本体与宠物装备的穿脱只改两处状态:宠物背包 list 7,以及 act 穿戴槽
|
||
// 26(宠物本体)/ 27-29(宠物装备)。list 0/1/2/38 在这次操作里一个字节都不会
|
||
// 变。重发这 4 张无关表代价极大:当前 EXE 在 op19 ACK 之后要先把已经收到的整批
|
||
// 容器表处理完,才轮到后面那些包。2026-09-24 公网实测(用 30 秒 op1206 心跳
|
||
// 标定时钟差 −4.384s、抖动 ±10ms,证明链路本身没有在途延迟):ACK 到达后 11ms
|
||
// 就被处理,但紧随其后的宠物行与 mode0 晚了 1.65~1.71 秒——这 1.7 秒正是客户端
|
||
// 在重建背包页。本机同一套表只要 ~70ms,因为环回的电报一次性到达、客户端只重建
|
||
// 一次。现场表现就是"脱下后背包图标要等一下才出现""穿上后要几秒宠物才出现"。
|
||
// 因此宠物穿脱只重建真正变化的 list 7;这与公会勋章那次把五表刷新收窄成单表
|
||
// 刷新(见 PostActionRefreshSelectedPersonalCargo)是同一处理。
|
||
//
|
||
// 它与 PostActionRefreshSelectedPetInventory 的区别只在于收尾:那个动作服务于
|
||
// 宠物改名/喂养这类消耗品,不补发锁表;而穿脱之前走的是五表刷新,末尾会补发
|
||
// 排序锁与 op251 物品锁快照(op13 单表重建同样会丢掉客户端进程内的锁标记),
|
||
// 所以这里必须原样保留这两发,否则会连带改掉锁图标行为。
|
||
PostActionRefreshSelectedWornCreatureInventory PostAction = "refresh_selected_worn_creature_inventory"
|
||
// Personal cargo sorting must rebuild only list 2. Replaying list 0 before
|
||
// the open warehouse list makes the current client re-home stackable ticket
|
||
// objects into the backpack during its native sort reconciliation.
|
||
PostActionRefreshSelectedPersonalCargo PostAction = "refresh_selected_personal_cargo"
|
||
// Moving between list 0 and the open personal cargo makes the current
|
||
// client discard its process-local wallet and fixed account-shared rows.
|
||
// Re-publish only those authoritative rows after the native op19 ACK; a full
|
||
// list-0 replay would replace unrelated live inventory objects.
|
||
PostActionRefreshSelectedMainWallets PostAction = "refresh_selected_main_wallets"
|
||
// Equipment slot item rows are list 3. They carry mutable equipped-item
|
||
// instance state (for example socket/emblem tail bytes) that mode0
|
||
// appearance refreshes do not reliably update.
|
||
PostActionRefreshSelectedEquipmentSlots PostAction = "refresh_selected_equipment_slots"
|
||
// Worn creature slot 26 uses the legacy pet item row and must be refreshed
|
||
// through its existing current-client op14 path after a pet enchant.
|
||
PostActionRefreshSelectedEquippedCreatureItem PostAction = "refresh_selected_equipped_creature_item"
|
||
// Account cargo is list 12. It must be rebuilt by dnfbridge from the
|
||
// authenticated account record and account inventory, never by replaying a
|
||
// captured cargo body or reading the selected character's settings.
|
||
PostActionRefreshSelectedAccountCargo PostAction = "refresh_selected_account_cargo"
|
||
// 仓库底板(cargo pad)重建:当前 EXE 的仓库/金库面板在**创建时**按容量铺格子,
|
||
// 之后收到的 op13 列表只更新行数据,不重建底板。现场 2026-09-18:买了"扩大帐号金库"
|
||
// 之后格子还是画着锁、实际能放东西,切一下仓库页才正常。修复方式就是照进城镇那套
|
||
// 顺序补一发——先重置 cargo pad(op391),再发物品列表。只用于**容量发生变化**的
|
||
// 操作(开通/升级账号金库、扩容券)。
|
||
PostActionResetCargoPadBeforeItemLists PostAction = "reset_cargo_pad_before_item_lists"
|
||
PostActionRefreshSelectedActorSkills PostAction = "refresh_selected_actor_skills"
|
||
// Immediate EXP items and level-up tickets mutate the absolute character
|
||
// progression snapshot. The current client needs op37 before its skill and
|
||
// quest projections; an op44 ACK and source-row refresh alone leave the HUD
|
||
// and visible level stale.
|
||
PostActionRefreshSelectedCharacterProgression PostAction = "refresh_selected_character_progression"
|
||
// Fatigue potions update the persisted remaining/used counters. The bridge
|
||
// sends the absolute current-fatigue packet after the item ACK and refresh.
|
||
PostActionRefreshSelectedFatigue PostAction = "refresh_selected_fatigue"
|
||
// A [rent point] item charges the account rental wallet. The bridge sends
|
||
// the absolute wallet state (op985) after the item ACK and row refresh;
|
||
// op985 replaces the client's cached balance, so nothing else is needed
|
||
// for the new 幸运星 count to show.
|
||
PostActionRefreshSelectedRentalWallet PostAction = "refresh_selected_rental_wallet"
|
||
// Creature state is the current scene class0/op105 raw table. The bridge
|
||
// sends it only after the selected actor is scene-ready; it is not part of
|
||
// the class1/op19 success body.
|
||
PostActionRefreshSelectedCreatureState PostAction = "refresh_selected_creature_state"
|
||
// Pet-feed mutations update the creature table and equipped growth body,
|
||
// but must not replay a list-7 op14 row immediately after op44. The legacy
|
||
// client can crash on that row sequence, so the bridge uses this narrower
|
||
// post-ACK projection.
|
||
PostActionRefreshSelectedCreatureGrowthState PostAction = "refresh_selected_creature_growth_state"
|
||
// Pet-panel consumable rows use the stable full list-7 op13 projection
|
||
// after the native op100 rename ACK; the incremental list-7 op14 path is
|
||
// not stable on this client.
|
||
PostActionRefreshSelectedPetInventory PostAction = "refresh_selected_pet_inventory"
|
||
// Crystal-contract state is current NoPack class1/op898. It must follow a
|
||
// durable type-97 activation so the native client can bind its persisted
|
||
// cube selection to the newly active contract immediately.
|
||
PostActionRefreshCrystalContractState PostAction = "refresh_crystal_contract_state"
|
||
// Damage-font ownership is a character-stat projection consumed by NOTI
|
||
// 1239. It follows the class1 op515 ACK and source-slot refresh.
|
||
PostActionRefreshSelectedDamageFontState PostAction = "refresh_selected_damage_font_state"
|
||
// Party-frame state is carried inside the current NoPack class0/op9 kind0
|
||
// actor record. The bridge rebuilds that record from the selected actor and
|
||
// live party slots before op153/op7 update the visible frame.
|
||
PostActionRefreshSelectedPartyFrame PostAction = "refresh_selected_party_frame"
|
||
// Quest completion tickets mutate the completed bitmap and may unlock new
|
||
// active/acceptable entries. The bridge reloads all three native projections
|
||
// only after op44 ACK and the source-slot refresh have been sent.
|
||
PostActionRefreshSelectedQuestState PostAction = "refresh_selected_quest_state"
|
||
// The current client renders op401/op437 failure ACKs as a silent failure.
|
||
// Ask the bridge to add a visible explanation only when the inventory owner
|
||
// proved that the rejected magic-seal mutation was caused by insufficient
|
||
// gold; all other rejection paths remain protocol-only.
|
||
PostActionNotifyRandomOptionGoldInsufficient PostAction = "notify_random_option_gold_insufficient"
|
||
// 【op455 成功后的系统提示】
|
||
//
|
||
// 品级调整(class-1/op81)的"已成功重调该物品的品级"是客户端自己弹出的:
|
||
// 客户端 op81 处理器收到成功 ACK 后本地显示,服务端只发那一个回包。
|
||
// 封印属性品级调整(class-1/op455)不同:当前客户端为 op455 注册的处理器
|
||
// 0x2137820 只在【失败】时按错误码弹提示(cmp byte [ebp+0xc],0 / jne 直接
|
||
// 返回),成功分支什么都不做 —— 所以成功提示必须由服务端补,否则玩家只看到
|
||
// 词条数字变了、没有任何反馈。
|
||
// 这里沿用与 op81 相同措辞的系统页提示(见 random_option_notice.go)。
|
||
PostActionNotifyRandomOptionResetSuccess PostAction = "notify_random_option_reset_success"
|
||
// Account-shared cube/soul material deleted inside an active dungeon run
|
||
// needs the current EXE's empty S0/0012 "inventory action complete" frame;
|
||
// its S0/0012 reader collapses the shared inventory-action wait even with
|
||
// entry_count=0. Without it the busy state bleeds to the next map and blocks
|
||
// ordinary consumable use (us90 opcode0012-delete_item.py account_material_in_run).
|
||
PostActionSendDeleteItemAccountMaterialCompletion PostAction = "send_delete_item_account_material_completion"
|
||
)
|
||
|
||
// UpperResponse 描述已经有证据允许 dnfbridge 发出的 raw upper 响应。
|
||
// Body 必须是完整业务包体;如果包体已经包含成功字节,调用方不能再套 upperSuccessBody。
|
||
type UpperResponse struct {
|
||
MsgID uint16
|
||
Body []byte
|
||
Classification byte
|
||
AllowCodec bool
|
||
}
|
||
|
||
// DailyUseState is one item's daily use counter after a command mutated it.
|
||
// Used counts how many times the character has consumed the item in the current
|
||
// service day; the client shows the remaining value itself (limit - used).
|
||
type DailyUseState struct {
|
||
ItemID uint32
|
||
Used byte
|
||
}
|
||
|
||
// ItemCooltimeState is one item's durable cooldown window after a command
|
||
// started it. ExpireAt is a unix second: it survives a 小退/重上, unlike the
|
||
// client's own in-memory cooldown gate.
|
||
type ItemCooltimeState struct {
|
||
ItemID int64
|
||
ExpireAt int64
|
||
}
|
||
|
||
// Handler 是单个 DNF 业务域的命令处理入口。
|
||
type Handler interface {
|
||
Domain() dnfenum.AlignedDomain
|
||
Handle(context.Context, Request) (Result, error)
|
||
}
|
||
|
||
// Registry 按业务域保存 handler,避免 dnfbridge 直接堆积各玩法 switch。
|
||
type Registry struct {
|
||
handlers map[dnfenum.AlignedDomain]Handler
|
||
}
|
||
|
||
// NewRegistry 创建模块化命令注册表。
|
||
func NewRegistry(handlers ...Handler) Registry {
|
||
registry := Registry{handlers: make(map[dnfenum.AlignedDomain]Handler)}
|
||
for _, handler := range handlers {
|
||
if handler == nil {
|
||
continue
|
||
}
|
||
registry.handlers[handler.Domain()] = handler
|
||
}
|
||
return registry
|
||
}
|
||
|
||
// DefaultRegistry 返回当前可用的模块化注册表。
|
||
// 具体业务 handler 尚未补齐时保持空表,只做证据化 pending/blocked 分流。
|
||
func DefaultRegistry() Registry {
|
||
return NewRegistry()
|
||
}
|
||
|
||
// Route 根据 EXE 证据分类并交给对应业务域 handler。
|
||
func (r Registry) Route(ctx context.Context, req Request) (Result, bool, error) {
|
||
// A numeric type is only a CmdPacket inside the current EXE command
|
||
// dispatcher. Other command classes have independent registration tables.
|
||
if req.CommandKnown && dnfenum.GameCmd(req.Command) != dnfenum.GameCmdCommand {
|
||
return Result{}, false, nil
|
||
}
|
||
decision, ok := Classify(req.Opcode)
|
||
if !ok {
|
||
return Result{}, false, nil
|
||
}
|
||
if decision.Action == ActionBlocked {
|
||
return Result{
|
||
Decision: decision,
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: string(decision.Action),
|
||
Reason: decision.Reason,
|
||
}, true, nil
|
||
}
|
||
handler := r.handlers[decision.Domain]
|
||
if handler == nil {
|
||
return Result{
|
||
Decision: decision,
|
||
Handled: true,
|
||
ResponseAllowed: false,
|
||
Operation: "module_unregistered",
|
||
Reason: "模块 handler 尚未注册",
|
||
}, true, nil
|
||
}
|
||
req.Body = append([]byte(nil), req.Body...)
|
||
result, err := handler.Handle(ctx, req)
|
||
if result.Decision == (Decision{}) {
|
||
result.Decision = decision
|
||
}
|
||
for i := range result.UpperResponses {
|
||
result.UpperResponses[i].Body = append([]byte(nil), result.UpperResponses[i].Body...)
|
||
}
|
||
for i := range result.AreaBroadcastResponses {
|
||
result.AreaBroadcastResponses[i].Body = append([]byte(nil), result.AreaBroadcastResponses[i].Body...)
|
||
}
|
||
for i := range result.PostItemSlotRefreshResponses {
|
||
result.PostItemSlotRefreshResponses[i].Body = append([]byte(nil), result.PostItemSlotRefreshResponses[i].Body...)
|
||
}
|
||
result.PostResponseItemSlotClears = append([]ItemSlotRefresh(nil), result.PostResponseItemSlotClears...)
|
||
result.EpicBookBalances = append([]EpicBookBalanceProjection(nil), result.EpicBookBalances...)
|
||
result.PostActions = append([]PostAction(nil), result.PostActions...)
|
||
return result, true, err
|
||
}
|
||
|
||
// EffectItemInfoProjection 是客户端 effect-item 记录的内容。
|
||
type EffectItemInfoProjection struct {
|
||
// ItemID 是触发效果的道具(客户端按它去 variousbuffstackablelist.etc 查 buff)。
|
||
ItemID uint32
|
||
// RemainingSeconds 是剩余秒数;<=0 表示应当清除。
|
||
RemainingSeconds uint32
|
||
}
|
||
|
||
// CharacterAddBuffProjection 是一条 buff 注册/注销通知。
|
||
type CharacterAddBuffProjection struct {
|
||
// BuffID 是本次掷点得到的 buff id(1136 等),必须 > 0。
|
||
BuffID int32
|
||
// RemoveBuffID 是同组旧 buff(换了档位/换了药剂时先撤掉它),0 表示无需注销。
|
||
RemoveBuffID int32
|
||
// ExpireAtUnix 是这次注册的 buff **到期时刻**(Unix 秒,0 = 不排到期定时器)。
|
||
//
|
||
// 为什么需要它:op487 的记录体里**没有时长**(三个状态字段一律 0),客户端不会自己
|
||
// 摘图标。参考服务端的做法是 SendAddAsync 之后 ScheduleRemoveForCurrentEffect ——
|
||
// 服务端到期时补发 CHARACTER_DEL_BUFF。bridge 就用这个字段武装那个定时器。
|
||
ExpireAtUnix int64
|
||
}
|