服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1080 lines
48 KiB
Go
1080 lines
48 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||
dnfitemcooldown "longheng.io/server/internal/modules/dnf/itemcooldown"
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
)
|
||
|
||
var fatiguePotionMaximumRemainingPattern = regexp.MustCompile(`(?i)(?:低于|below)\s*([0-9]+)`)
|
||
|
||
// currentPVFItemCooltimeGroup 返回某件道具的 PVF `[cooltime group]`(0 = 无组)。
|
||
//
|
||
// 它必须能解析**已经在角色冷却表里的**item id:冷却检查要判断"上一次用的是不是同组
|
||
// 的另一件道具"(PVF [cooltime group],例如 66 组里的首日/次日/三日礼盒共用一天冷却)。
|
||
// 解析不到、或那件道具本身没有 [cooltime maintenance] 时返回 0 = 不参与共享。
|
||
func (s *Service) currentPVFItemCooltimeGroup(itemID int64) int64 {
|
||
if s == nil || itemID <= 0 || itemID > int64(^uint32(0)) {
|
||
return 0
|
||
}
|
||
catalog, err := s.currentPVFItemCatalog()
|
||
if err != nil || catalog == nil {
|
||
return 0
|
||
}
|
||
definition, err := catalog.ResolveItem(uint32(itemID))
|
||
if err != nil || !definition.HasCooltimeMaintenance {
|
||
return 0
|
||
}
|
||
if definition.CooltimeGroup <= 0 {
|
||
return 0
|
||
}
|
||
return definition.CooltimeGroup
|
||
}
|
||
|
||
// alignedStackableUseResolverForCommand authorizes ordinary type-44 use and
|
||
// action-based quest tickets from the active PVF. Inventory rows from older
|
||
// grants may carry the real PVF path
|
||
// but lack stackable_type; the owner combines this resolver with that durable
|
||
// path. Main-inventory use remains restricted to [waste], while list-7 pet
|
||
// consumable use additionally requires a complete [feed] int-data contract.
|
||
func (s *Service) alignedStackableUseResolverForCommand(opcode dnfenum.CmdPacket) (alignedcmd.StackableUseResolver, error) {
|
||
if opcode != dnfenum.CmdPacketUseStackable && opcode != dnfenum.CmdPacketUseStackableAction &&
|
||
opcode != dnfenum.CmdPacketRequestEventServerLevelUp && opcode != dnfenum.CmdPacketIncreaseStatus &&
|
||
opcode != dnfenum.CmdPacketUpgradeInventory && opcode != dnfenum.CmdPacketUpgradeCargo {
|
||
// op30 carries no item identity, so its owner resolves the durable row
|
||
// through this same resolver before anything is consumed.
|
||
//
|
||
// op430/op408(扩容券)同样没有 item id,只有一个槽位;owner 必须靠这个
|
||
// 解析器把"槽上那件东西到底是不是扩容券、是哪一种"从运行期 PVF 判出来。
|
||
return nil, nil
|
||
}
|
||
catalog, err := s.currentPVFItemCatalog()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||
if itemID <= 0 || itemID > int64(^uint32(0)) {
|
||
return alignedcmd.StackableUseResolution{}, nil
|
||
}
|
||
definition, resolveErr := catalog.ResolveItem(uint32(itemID))
|
||
if resolveErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("resolve stackable-use item=%d: %w", itemID, resolveErr)
|
||
}
|
||
if definition.Kind != dungeonDropItemStackable {
|
||
return alignedcmd.StackableUseResolution{}, nil
|
||
}
|
||
resolution := alignedcmd.StackableUseResolution{
|
||
ItemID: itemID,
|
||
PVFPath: definition.PVFPath,
|
||
StackableType: definition.StackableType,
|
||
}
|
||
// PVF [total usable count] is the item's own 每天可用次数 cap and is not
|
||
// fatigue-specific: the parser already records it for every stackable,
|
||
// so every consumable that declares it gets the shared per-character
|
||
// daily counter (inventory.Owner.ReserveDailyItemUse) plus its op541
|
||
// projection. A missing field leaves the limit at zero = no cap and no
|
||
// extra work.
|
||
if definition.TotalUsableCount > 0 {
|
||
resolution.DailyUseLimit = definition.TotalUsableCount
|
||
}
|
||
// PVF 冷却:只有声明了 `[cooltime maintenance]`(值非零)的道具才由服务端**持久**
|
||
// 保留冷却窗口并据此拒绝再次使用。客户端的冷却门只在进程内内存里,小退重上就没了
|
||
// (现场 2026-09-17:1 天冷却的消耗品重上后又能用)。[cool time] 是毫秒,
|
||
// [cooltime group] 让同组道具共享一个窗口(0 = 只锁自己)。
|
||
if definition.HasCooltimeMaintenance && definition.CooltimeMS > 0 {
|
||
if seconds := dnfitemcooldown.MillisecondsToSeconds(definition.CooltimeMS); seconds > 0 {
|
||
resolution.MaintainsCooltime = true
|
||
resolution.CooltimeSeconds = seconds
|
||
resolution.CooltimeGroup = definition.CooltimeGroup
|
||
}
|
||
}
|
||
admissionTicket, admissionErr := s.currentPVFDungeonAdmissionTicket(definition)
|
||
if admissionErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("resolve dungeon-admission ticket item=%d path=%s: %w", itemID, definition.PVFPath, admissionErr)
|
||
}
|
||
if admissionTicket != nil {
|
||
resolution.DungeonAdmissionTicket = admissionTicket
|
||
return resolution, nil
|
||
}
|
||
// 塔罗牌(`[monster tarot card]`):金币花费 + 加权奖池。分支就挂在这条既有
|
||
// 的 op44 分派链上,与别的 [stackable type] 同一写法,没有新通道。
|
||
if normalizeDungeonDropStackableType(definition.StackableType) == "[monster tarot card]" {
|
||
lottery, lotteryErr := s.resolveCurrentTarotLotteryCard(definition)
|
||
if lotteryErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, lotteryErr
|
||
}
|
||
resolution.TarotLottery = lottery
|
||
return resolution, nil
|
||
}
|
||
if strings.EqualFold(normalizeDungeonDropStackableType(definition.StackableType), "[expert town potion]") &&
|
||
normalizeDungeonDropStackableType(definition.ActionType) == "[add fatigue]" {
|
||
if definition.ActionValues == nil || len(definition.ActionValues.Values) != 1 || definition.ActionValues.Values[0] <= 0 {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("resolve fatigue potion item=%d path=%s: invalid add-fatigue values=%v", itemID, definition.PVFPath, definition.ActionValues)
|
||
}
|
||
maximumRemaining := int64(0)
|
||
if matches := fatiguePotionMaximumRemainingPattern.FindStringSubmatch(definition.Explain); len(matches) == 2 {
|
||
fmt.Sscan(matches[1], &maximumRemaining)
|
||
}
|
||
resolution.FatigueRecovery = definition.ActionValues.Values[0]
|
||
resolution.FatigueMaximumRemaining = maximumRemaining
|
||
return resolution, nil
|
||
}
|
||
// The current client routes [action type] `[rent point]` through
|
||
// op515 action index 105 (see currentRentalPointActionIndex). The
|
||
// grant comes from the item's own action value; the ceiling is the
|
||
// rental system's [limit point], so both travel with the resolution
|
||
// and the wallet stays clamped inside the commit transaction.
|
||
if normalizeDungeonDropStackableType(definition.ActionType) == "[rent point]" {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[waste]" ||
|
||
definition.ActionValues == nil || len(definition.ActionValues.Values) != 1 ||
|
||
definition.ActionValues.Values[0] <= 0 || definition.ActionValues.Values[0] > int64(^uint32(0)) {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve rental point item=%d path=%s: incomplete [rent point] contract type=%q values=%v",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.StackableType,
|
||
definition.ActionValues,
|
||
)
|
||
}
|
||
catalog, catalogErr := s.currentRentalCatalogOnly(context.Background())
|
||
if catalogErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("resolve rental point item=%d limit: %w", itemID, catalogErr)
|
||
}
|
||
if catalog == nil || catalog.Limit == 0 {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf("resolve rental point item=%d: rental [limit point] unavailable", itemID)
|
||
}
|
||
resolution.RentalPoint = &alignedcmd.RentalPointGrantResolution{
|
||
Grant: uint32(definition.ActionValues.Values[0]),
|
||
Limit: catalog.Limit,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
// 扩容券三兄弟:`[action type]` 为 [upgrade inventory] / [upgrade cargo] /
|
||
// [upgrade account cargo]。现场 2026-10-03(测试x):使用后没效果 —— 服务端对
|
||
// op430/op408 完全没有接线,解析器里也没有这三个 action type 的分支。
|
||
//
|
||
// 全库共 100 件道具声明这三种 action(40 / 47 / 13),**全部**是
|
||
// `[stackable type] [etc]` + `[action usable place] [village]`,所以按家族判定,
|
||
// 不按 id 硬编码。
|
||
if upgradeKind, upgradeKindOK := currentContainerUpgradeKind(definition.ActionType); upgradeKindOK {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve container-upgrade voucher item=%d path=%s: action=%q requires a bare [etc] stackable, got type=%q",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.ActionType,
|
||
definition.StackableType,
|
||
)
|
||
}
|
||
if !definition.ActionUsableVillage {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve container-upgrade voucher item=%d path=%s: action=%q requires [action usable place] [village]",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.ActionType,
|
||
)
|
||
}
|
||
resolution.ContainerUpgrade = &alignedcmd.ContainerUpgradeResolution{
|
||
Kind: upgradeKind,
|
||
ItemID: itemID,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
if normalizeCustomAbilityGroupConverterAction(definition.ActionType) {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" || definition.MinimumLevel <= 0 {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve custom-ability group converter item=%d path=%s: incomplete [etc] contract type=%q minimum_level=%d",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.StackableType,
|
||
definition.MinimumLevel,
|
||
)
|
||
}
|
||
resolution.CustomAbilityGroupConverter = &alignedcmd.CustomAbilityGroupConverterResolution{
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
// 城镇自施放增益药剂(史诗Buff药剂一族):`[stackable type] [expert town potion]`
|
||
// + **没有** `[action type]` + `[stat change duration]`。
|
||
//
|
||
// 现场 2026-10-03(服主报,角色 测试x = char 11):史诗Buff药剂 490002458
|
||
// 连点 15 次,op44 每一次都被 useStackableWithResolvers 的
|
||
// "requires PVF-proven [waste] provenance" 那道门静默拒掉(error 17):
|
||
// 既没消耗、也没有效果,玩家看到的就是"没反应"。
|
||
//
|
||
// 这一族的效果是**服务端量**(深渊派对史诗掉落率),所以服务端必须自己记住
|
||
// 增益窗口,见 internal/modules/dnf/statbuff + dungeon_hell_party_drop.go。
|
||
//
|
||
// 这里**不**要求 `[action usable place]`:那是"使用地点的限制",不是"这是增益
|
||
// 药剂"的证据。全库有 9 件同族根本没写这个字段(赛丽亚的幸运*药剂、强化秘药、
|
||
// 和平的呐喊…),客户端自己也按同一份 PVF 判地点,服务端照做即可。
|
||
if currentStatChangePotionDefinition(definition) {
|
||
potion := &alignedcmd.StatChangePotionResolution{
|
||
EffectDurationSeconds: currentStatChangePotionEffectDurationSeconds(definition),
|
||
}
|
||
// 权威来源:两张 buff 表(道具→buff+概率、buff→效果数值)。
|
||
// 同一张概率表里可能混着不同效果的 buff,按 effect name 分流:
|
||
// 掉落率 / 强化成功率 / 通关经验 —— 服务端要自己算的三种;
|
||
// 其余(城镇移速 village_move_speed、进图属性 move_speed …)——
|
||
// 客户端认得这些 buff、自己按 buff 表施加,服务端只要把掷到的 buff 注册过去。
|
||
if tables, tablesOK := catalog.currentVariousBuffTables(); tablesOK {
|
||
for _, roll := range tables.rollsForItem(itemID, currentEpicRarityDropRateEffect) {
|
||
effect := tables.ByBuff[roll.BuffID]
|
||
bonus, bonusOK := effect.dropRateBonusPercent()
|
||
if !bonusOK {
|
||
continue
|
||
}
|
||
potion.BuffRolls = append(potion.BuffRolls, alignedcmd.StatChangePotionBuffRoll{
|
||
BuffID: roll.BuffID,
|
||
Weight: roll.Weight,
|
||
BonusPercent: bonus,
|
||
})
|
||
}
|
||
for _, roll := range tables.rollsForItem(itemID, currentUpgradeSuccessRateEffect) {
|
||
effect := tables.ByBuff[roll.BuffID]
|
||
bonus, bonusOK := effect.upgradeSuccessBonusPercent()
|
||
if !bonusOK {
|
||
continue
|
||
}
|
||
potion.UpgradeKind = currentUpgradeRatePotionReinforce
|
||
potion.UpgradeRolls = append(potion.UpgradeRolls, alignedcmd.StatChangePotionWeightedRoll{
|
||
BuffID: roll.BuffID,
|
||
Weight: roll.Weight,
|
||
BonusPercent: bonus,
|
||
})
|
||
}
|
||
for _, roll := range tables.rollsForItem(itemID, currentDungeonClearExpRateEffect) {
|
||
effect := tables.ByBuff[roll.BuffID]
|
||
bonus, bonusOK := effect.dungeonClearExpBonusPercent()
|
||
if !bonusOK {
|
||
continue
|
||
}
|
||
potion.DungeonClearExpRolls = append(potion.DungeonClearExpRolls,
|
||
alignedcmd.StatChangePotionWeightedRoll{
|
||
BuffID: roll.BuffID,
|
||
Weight: roll.Weight,
|
||
BonusPercent: bonus,
|
||
})
|
||
}
|
||
// 剩下的族:客户端自施放(服务端只负责注册 buff)。
|
||
family, familyRolls := tables.clientBuffRollsForItem(itemID)
|
||
if len(familyRolls) > 0 {
|
||
potion.ClientBuffFamily = family
|
||
for _, roll := range familyRolls {
|
||
potion.ClientBuffRolls = append(potion.ClientBuffRolls, alignedcmd.StatChangePotionWeightedRoll{
|
||
BuffID: roll.BuffID,
|
||
Weight: roll.Weight,
|
||
BonusPercent: roll.BonusPercent,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
// 回退:表里没有条目(490000413 这类 2016 老活动券)才看说明文字。
|
||
if len(potion.BuffRolls) == 0 {
|
||
potion.EpicDropRatePercent = currentEpicDropRatePotionPercent(definition.Explain)
|
||
}
|
||
// 强化秘药这类"表里查不到、说明文字只说能提升强化成功率"的道具:给固定加成。
|
||
if potion.UpgradeKind == "" && len(potion.UpgradeRolls) == 0 &&
|
||
currentUpgradeRatePotionExplainKind(definition.Explain) == currentUpgradeRatePotionReinforce {
|
||
potion.UpgradeKind = currentUpgradeRatePotionReinforce
|
||
potion.UpgradeBonusPercent = currentUpgradeRatePotionDefaultBonusPercent
|
||
}
|
||
resolution.StatChangePotion = potion
|
||
return resolution, nil
|
||
}
|
||
// 强化/增幅成功率药剂:`[stackable type] [item upgrad buff usual upgrade]` /
|
||
// `[item upgrad buff amplify upgrade]`(强化的奥秘 / 永恒的增幅的秘密)。
|
||
// 它们不是 [expert town potion] 户籍,所以走单独一条判据;这两类在 PVF 里同样
|
||
// **没有数字字段**,加成取同一个常量。
|
||
if kind, kindOK := currentUpgradeRatePotionStackableKind(definition.StackableType); kindOK &&
|
||
definition.EffectDurationMS > 0 {
|
||
resolution.StatChangePotion = &alignedcmd.StatChangePotionResolution{
|
||
EffectDurationSeconds: currentStatChangePotionEffectDurationSeconds(definition),
|
||
UpgradeKind: kind,
|
||
UpgradeBonusPercent: currentUpgradeRatePotionDefaultBonusPercent,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
avatarFittingTicket, fittingErr := currentPVFAvatarFittingRoomTicket(catalog.source, definition)
|
||
if fittingErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fittingErr
|
||
}
|
||
if avatarFittingTicket {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" ||
|
||
definition.MinimumLevel <= 0 ||
|
||
(!definition.ActionUsableVillage && !definition.ActionUsableSeriaRoom) {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve avatar-fitting ticket item=%d path=%s: incomplete [etc] contract type=%q minimum_level=%d village=%t seria=%t",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.StackableType,
|
||
definition.MinimumLevel,
|
||
definition.ActionUsableVillage,
|
||
definition.ActionUsableSeriaRoom,
|
||
)
|
||
}
|
||
resolution.AvatarFittingRoomTicket = &alignedcmd.AvatarFittingRoomTicketResolution{
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
VillageAllowed: definition.ActionUsableVillage,
|
||
SeriaAllowed: definition.ActionUsableSeriaRoom,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
if book, ok := currentPVFSkillPointBook(definition.PVFPath); ok {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" ||
|
||
definition.MinimumLevel <= 0 ||
|
||
(book.Kind != alignedcmd.SkillPointBookResetTP && book.Delta <= 0) {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve SP/TP book item=%d path=%s: incomplete [etc] contract type=%q minimum_level=%d kind=%d delta=%d",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.StackableType,
|
||
definition.MinimumLevel,
|
||
book.Kind,
|
||
book.Delta,
|
||
)
|
||
}
|
||
book.MinimumLevel = definition.MinimumLevel
|
||
book.MaximumLevel = definition.MaximumLevel
|
||
resolution.TPBook = &alignedcmd.TPBookResolution{
|
||
Kind: book.Kind,
|
||
Delta: book.Delta,
|
||
MinimumLevel: book.MinimumLevel,
|
||
MaximumLevel: book.MaximumLevel,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
if actionType := normalizeStoryBookAction(definition.ActionType); actionType != "" {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[waste]" ||
|
||
definition.ActionValues == nil || len(definition.ActionValues.Values) != 1 ||
|
||
definition.ActionValues.Values[0] <= 0 || definition.ActionValues.Values[0] > int64(^uint16(0)) {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve story-book item=%d path=%s: invalid contract type=%q values=%v",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.StackableType,
|
||
definition.ActionValues,
|
||
)
|
||
}
|
||
resolution.StoryBook = &alignedcmd.StoryBookResolution{
|
||
ActionType: actionType,
|
||
QuestID: definition.ActionValues.Values[0],
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
actionType := normalizeQuestCompletionTicketAction(definition.ActionType)
|
||
if actionType != "" {
|
||
var actionValues []int64
|
||
if definition.ActionValues != nil {
|
||
actionValues = append([]int64(nil), definition.ActionValues.Values...)
|
||
}
|
||
resolution.QuestCompletionTicket = &alignedcmd.QuestCompletionTicketResolution{
|
||
ActionType: actionType,
|
||
ActionValues: actionValues,
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
VillageOnly: definition.ActionVillageOnly,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
if normalizeLevelUpTicketAction(definition.ActionType) != "" {
|
||
if definition.LevelUpRange <= 0 || definition.LevelUpRange > int64(currentAdventureCharacterLevelCap) {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve level-up ticket item=%d path=%s: invalid levelup range %d",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.LevelUpRange,
|
||
)
|
||
}
|
||
levelUpRange, rangeErr := rollLevelUpTicketRange(definition)
|
||
if rangeErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve level-up ticket item=%d path=%s outcome: %w",
|
||
itemID,
|
||
definition.PVFPath,
|
||
rangeErr,
|
||
)
|
||
}
|
||
// The current PVF models level-up tickets as an action rather than an
|
||
// [increase status type]. Keep both mutations explicit: the direct
|
||
// progression plan raises the character by [levelup range], while the
|
||
// quest plan completes the eligible pre-upgrade story/ordinary chain
|
||
// without ordinary rewards. The inventory owner recognizes the pair
|
||
// and commits them with the source decrement in one settlement.
|
||
resolution.DirectExperience = &alignedcmd.DirectExperienceConsumableResolution{
|
||
ItemID: itemID,
|
||
PVFPath: definition.PVFPath,
|
||
EffectType: "[level up ticket]",
|
||
EffectValues: []int64{levelUpRange},
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
}
|
||
resolution.QuestCompletionTicket = &alignedcmd.QuestCompletionTicketResolution{
|
||
ActionType: "[quest clear]",
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
VillageOnly: definition.ActionVillageOnly,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
directExperienceType := normalizeDirectExperienceType(definition.DirectExperienceType)
|
||
if directExperienceType != "" {
|
||
if validateErr := validateDirectExperienceValues(directExperienceType, definition.DirectExperienceValues); validateErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve direct-experience item=%d path=%s: %w",
|
||
itemID,
|
||
definition.PVFPath,
|
||
validateErr,
|
||
)
|
||
}
|
||
resolution.DirectExperience = &alignedcmd.DirectExperienceConsumableResolution{
|
||
ItemID: itemID,
|
||
PVFPath: definition.PVFPath,
|
||
EffectType: directExperienceType,
|
||
EffectValues: append([]int64(nil), definition.DirectExperienceValues...),
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
if amount, ok := currentPVFCharacterExperienceBook(definition.PVFPath); ok {
|
||
if auditErr := auditCurrentPVFCharacterExperienceBook(definition, amount); auditErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve character experience book item=%d path=%s: %w",
|
||
itemID,
|
||
definition.PVFPath,
|
||
auditErr,
|
||
)
|
||
}
|
||
resolution.DirectExperience = &alignedcmd.DirectExperienceConsumableResolution{
|
||
ItemID: itemID,
|
||
PVFPath: definition.PVFPath,
|
||
EffectType: "[expup]",
|
||
EffectValues: []int64{amount},
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
}
|
||
return resolution, nil
|
||
}
|
||
transformation, transformErr := currentPVFTransformationUseResolution(definition)
|
||
if transformErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve transformation item=%d path=%s: %w",
|
||
itemID,
|
||
definition.PVFPath,
|
||
transformErr,
|
||
)
|
||
}
|
||
if transformation != nil {
|
||
resolution.Transformation = transformation
|
||
return resolution, nil
|
||
}
|
||
projectile, projectileErr := currentPVFProjectileUseResolution(definition)
|
||
if projectileErr != nil {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve projectile item=%d path=%s: %w",
|
||
itemID,
|
||
definition.PVFPath,
|
||
projectileErr,
|
||
)
|
||
}
|
||
if projectile != nil {
|
||
resolution.Projectile = projectile
|
||
return resolution, nil
|
||
}
|
||
switch normalized := normalizeDungeonDropStackableType(definition.StackableType); {
|
||
case strings.HasPrefix(normalized, "[waste]"):
|
||
if definition.ActionUsableVillage || definition.ActionUsableDungeon || definition.ActionUsableSeriaRoom ||
|
||
len(definition.ActionUsableDungeonIDs) > 0 || len(definition.LimitDungeonIDs) > 0 {
|
||
resolution.UsePlace = &alignedcmd.StackableUsePlaceResolution{
|
||
VillageAllowed: definition.ActionUsableVillage,
|
||
DungeonAllowed: definition.ActionUsableDungeon || len(definition.ActionUsableDungeonIDs) > 0 || len(definition.LimitDungeonIDs) > 0,
|
||
SeriaRoomAllowed: definition.ActionUsableSeriaRoom,
|
||
ActionUsableDungeonIDs: append([]int64(nil), definition.ActionUsableDungeonIDs...),
|
||
LimitDungeonIDs: append([]int64(nil), definition.LimitDungeonIDs...),
|
||
}
|
||
}
|
||
return resolution, nil
|
||
case normalized == "[feed]":
|
||
if definition.PetFeedEffectDurationSeconds <= 0 ||
|
||
definition.PetFeedSkillChargePercent >= 0 ||
|
||
definition.PetFeedSatietyDelta <= 0 || definition.PetFeedSatietyDelta > 100 {
|
||
return alignedcmd.StackableUseResolution{}, fmt.Errorf(
|
||
"resolve pet-feed item=%d path=%s: invalid int data duration=%d skill_charge=%d satiety=%d",
|
||
itemID,
|
||
definition.PVFPath,
|
||
definition.PetFeedEffectDurationSeconds,
|
||
definition.PetFeedSkillChargePercent,
|
||
definition.PetFeedSatietyDelta,
|
||
)
|
||
}
|
||
resolution.PetFeedEffectDurationSeconds = definition.PetFeedEffectDurationSeconds
|
||
resolution.PetFeedSkillChargePercent = definition.PetFeedSkillChargePercent
|
||
resolution.PetFeedSatietyDelta = definition.PetFeedSatietyDelta
|
||
return resolution, nil
|
||
default:
|
||
return alignedcmd.StackableUseResolution{}, nil
|
||
}
|
||
}, nil
|
||
}
|
||
|
||
// currentContainerUpgradeKind 识别扩容券的三条 [action type]。
|
||
//
|
||
// 口径见 alignedcmd.ContainerUpgradeResolution 的注释:现场 2026-10-03 三种券使用后
|
||
// 没效果的根因就是这里没人认它们。
|
||
func currentContainerUpgradeKind(actionType string) (alignedcmd.ContainerUpgradeKind, bool) {
|
||
switch normalizeDungeonDropStackableType(actionType) {
|
||
case "[upgrade inventory]":
|
||
return alignedcmd.ContainerUpgradeInventory, true
|
||
case "[upgrade cargo]":
|
||
return alignedcmd.ContainerUpgradeCargo, true
|
||
case "[upgrade account cargo]":
|
||
return alignedcmd.ContainerUpgradeAccountCargo, true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
// currentStatChangePotionDefinition 判定一件道具是不是"城镇自施放增益药剂"。
|
||
//
|
||
// 判据全部来自运行期 PVF,**不看物品 id、也不看名字**:
|
||
//
|
||
// [stackable type] `[expert town potion]` —— 户籍(本族全库 711 件)
|
||
// 没有 [action type] —— 有 action 的走各自通道
|
||
// ([add fatigue] 走 op515 action 58)
|
||
// [stat change duration] > 0 —— 它自己声明了"我是限时增益"
|
||
//
|
||
// 三条同时成立才认。为什么这就是"证据":`[stat change duration]` 是"给自己挂一个
|
||
// 限时 stat 变化"的专用字段,配上 [expert town potion] 户籍与"没有 action",
|
||
// 形状只有这一种含义;反过来,有 action 的道具(疲劳药、摆件、竞猜币…)统统留给
|
||
// 它们自己的通道,一条也不抢。
|
||
//
|
||
// 名字里有没有"史诗"完全不参与判定:判据命中的 32 件里既有 15 个 id 的史诗Buff药剂,
|
||
// 也有"小灯笼的大瓶·小瓶史诗Buff药剂""欧皇药水""城镇移速药剂""有机甜瓜汁""强化秘药"
|
||
// "和平的呐喊"——它们都是同一族。是否**影响掉落率**另有一道判据(说明文字里有"掉落率")。
|
||
func currentStatChangePotionDefinition(definition dungeonDropItemDefinition) bool {
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[expert town potion]" {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(definition.ActionType) != "" {
|
||
return false
|
||
}
|
||
return definition.EffectDurationMS > 0
|
||
}
|
||
|
||
// currentStatChangePotionEffectDurationSeconds 返回这次使用要落的窗口长度(秒)。
|
||
//
|
||
// 口径就是 PVF 的 `[stat change duration]`(本族 600000ms = 10 分钟)。2026-10-03 服主
|
||
// 做过一次"硬编码 1 分钟"的过期验证(确认图标会被正确摘掉),验证完已按服主要求改回
|
||
// PVF 口径 —— 这里**不留**任何测试旋钮。
|
||
func currentStatChangePotionEffectDurationSeconds(definition dungeonDropItemDefinition) int64 {
|
||
return definition.EffectDurationMS / 1000
|
||
}
|
||
|
||
// 强化/增幅成功率药剂(服主 2026-10-03:"看看增幅的秘密 强化秘药这类增加强化增幅成功率的
|
||
// 道具 也一并修复了")。判定只认 PVF 的 `[stackable type]`,不看 id、不看名字。
|
||
const (
|
||
// currentUpgradeRatePotionReinforce 对应 `[stackable type] [item upgrad buff usual upgrade]`
|
||
// (强化成功率,例:强化的奥秘 490007878)。
|
||
currentUpgradeRatePotionReinforce = "reinforce"
|
||
// currentUpgradeRatePotionAmplify 对应 `[stackable type] [item upgrad buff amplify upgrade]`
|
||
// (增幅成功率,例:永恒的增幅的秘密 490001534)。
|
||
currentUpgradeRatePotionAmplify = "amplify"
|
||
|
||
// currentUpgradeRatePotionDefaultBonusPercent 是"PVF 里没有数字"那几件的固定加成
|
||
// (百分点)。取值依据(同一份 PVF 内的证据,不是拍脑袋):
|
||
//
|
||
// crystallight_10007343(SAO 联动): "使用后,强化成功率增加10%%,效果持续30秒"
|
||
// fortuneaura_success: "在该频道中,增加10%%的强化成功率,效果持续60分钟"
|
||
// 赛丽亚的幸运强化药剂的三档: 10(下级)/ 20(中级)/ 30(上级)
|
||
//
|
||
// 强化秘药、强化的奥秘、永恒的增幅的秘密都只写"可以提升强化/增幅成功率",没有数字,
|
||
// 所以取这三处共同的下限值 10。要调整就改这一个常量(档位表里的 10/20/30 不受影响)。
|
||
currentUpgradeRatePotionDefaultBonusPercent int64 = 10
|
||
)
|
||
|
||
// currentUpgradeRatePotionStackableKind 识别"专用 stackable type"的强化/增幅药剂。
|
||
func currentUpgradeRatePotionStackableKind(stackableType string) (string, bool) {
|
||
switch normalizeDungeonDropStackableType(stackableType) {
|
||
case "[item upgrad buff usual upgrade]":
|
||
return currentUpgradeRatePotionReinforce, true
|
||
case "[item upgrad buff amplify upgrade]":
|
||
return currentUpgradeRatePotionAmplify, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// currentUpgradeRatePotionExplainKind 是最后一道回退:`[expert town potion]` 户籍、两张 buff
|
||
// 表里都没有条目时看说明文字。目前命中这一条的就是"强化秘药"("可以提升强化成功率")。
|
||
func currentUpgradeRatePotionExplainKind(explain string) string {
|
||
if !strings.Contains(explain, "成功率") {
|
||
return ""
|
||
}
|
||
switch {
|
||
case strings.Contains(explain, "强化成功率"):
|
||
return currentUpgradeRatePotionReinforce
|
||
case strings.Contains(explain, "增幅成功率"):
|
||
return currentUpgradeRatePotionAmplify
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// currentEpicDropRatePotionMultiplierPercent 是本族药剂对"深渊派对史诗掉落率"的倍率。
|
||
//
|
||
// 200 = 2 倍。为什么是常量而不是读 PVF:这一族在 PVF 里**根本没有数字字段**
|
||
// (`[stat change duration]` 只是窗口长度;`[icon mark]` 只是图标),"2 倍"只写在
|
||
// `[explain]` 的说明文字里,而官方服务端二进制里也查不到这些 id
|
||
// (_investigation_顶号_official/epicbuff_scan.py 扫过 490000413/490002458/…/10008219,
|
||
// 全部 0 命中)。所以口径取家族同名道具自己的说明:"史诗装备掉落率变为2倍"。
|
||
//
|
||
// 说明文字里只写"增加/小幅增加"而没有倍数的几件(小灯笼的大瓶·小瓶、欧皇药水)
|
||
// 同样按 200 处理:它们共享同一个 10 分钟窗口与 `[cooltime group] 251`,
|
||
// 所以不存在叠加放大;这一点在文档里显式记下来,运营若要区分可以再补表。
|
||
const currentEpicDropRatePotionMultiplierPercent int64 = 200
|
||
|
||
// currentEpicDropRatePotionPercent 从道具说明判断它是否影响掉落率。
|
||
//
|
||
// 只有说明里明确写"掉落率"的才给倍率:经验药剂、强化秘药、和平的呐喊、城镇移动速度
|
||
// 药剂这些同族道具说明里没有"掉落率",返回 0 = 服务端不改掉落(药剂照样能正常使用)。
|
||
func currentEpicDropRatePotionPercent(explain string) int64 {
|
||
if !strings.Contains(strings.TrimSpace(explain), "掉落率") {
|
||
return 0
|
||
}
|
||
return currentEpicDropRatePotionMultiplierPercent
|
||
}
|
||
|
||
func (s *Service) currentPVFDungeonAdmissionTicket(definition dungeonDropItemDefinition) (*alignedcmd.DungeonAdmissionTicketResolution, error) {
|
||
actionType := normalizeDungeonDropStackableType(definition.ActionType)
|
||
if actionType != "[add dimension dungeon enter count]" && actionType != "[up layer despair dungeon]" {
|
||
return nil, nil
|
||
}
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[waste]" ||
|
||
definition.ActionValues == nil || len(definition.ActionValues.Values) != 1 ||
|
||
definition.ActionValues.Values[0] <= 0 || definition.MinimumLevel <= 0 ||
|
||
(!definition.ActionUsableVillage && !definition.ActionUsableSeriaRoom) {
|
||
return nil, fmt.Errorf(
|
||
"incomplete contract type=%q values=%v minimum_level=%d village=%t seria=%t",
|
||
definition.StackableType,
|
||
definition.ActionValues,
|
||
definition.MinimumLevel,
|
||
definition.ActionUsableVillage,
|
||
definition.ActionUsableSeriaRoom,
|
||
)
|
||
}
|
||
s.worldMapMu.RLock()
|
||
catalog := s.dungeonAdmissionCatalog
|
||
s.worldMapMu.RUnlock()
|
||
if catalog == nil {
|
||
return nil, dnfdungeonadmission.ErrOwnerUnavailable
|
||
}
|
||
if definition.UseActionPacket < 0 || definition.UseActionPacket > int64(^uint32(0)) {
|
||
return nil, fmt.Errorf("invalid use action packet=%d", definition.UseActionPacket)
|
||
}
|
||
ticket := &alignedcmd.DungeonAdmissionTicketResolution{
|
||
ActionType: actionType,
|
||
ActionPacket: uint32(definition.UseActionPacket),
|
||
MinimumLevel: definition.MinimumLevel,
|
||
MaximumLevel: definition.MaximumLevel,
|
||
VillageAllowed: definition.ActionUsableVillage,
|
||
SeriaRoomAllowed: definition.ActionUsableSeriaRoom,
|
||
}
|
||
switch actionType {
|
||
case "[add dimension dungeon enter count]":
|
||
amount := definition.ActionValues.Values[0]
|
||
if amount > 5 {
|
||
return nil, fmt.Errorf("dimension-gate allowance %d exceeds the current-PVF daily additional cap 5", amount)
|
||
}
|
||
policy, found := catalog.Policy("dimension_gate.character.daily")
|
||
if !found || policy.Scope != dnfdungeonadmission.ScopeCharacter || policy.Period != dnfdungeonadmission.PeriodDaily {
|
||
return nil, fmt.Errorf("dimension-gate admission policy is unavailable")
|
||
}
|
||
ticket.Grants = []dnfdungeonadmission.AllowanceGrant{{Policy: policy, Amount: uint32(amount), MaximumBonus: 5}}
|
||
case "[up layer despair dungeon]":
|
||
target := definition.ActionValues.Values[0]
|
||
var prefix string
|
||
switch target {
|
||
case 11008:
|
||
prefix = "tower_of_despair"
|
||
case 5115:
|
||
prefix = "tower_of_grief"
|
||
default:
|
||
// Preserve the ordinary native-waste path for an unowned tower
|
||
// family instead of consuming it against a guessed counter.
|
||
return nil, nil
|
||
}
|
||
ticket.TargetDungeonID = uint32(target)
|
||
for _, key := range []string{prefix + ".character.daily", prefix + ".account.daily"} {
|
||
policy, found := catalog.Policy(key)
|
||
if !found || policy.Period != dnfdungeonadmission.PeriodDaily || !dungeonAdmissionPolicyContains(policy, ticket.TargetDungeonID) {
|
||
return nil, fmt.Errorf("tower admission policy %s is unavailable", key)
|
||
}
|
||
ticket.Grants = append(ticket.Grants, dnfdungeonadmission.AllowanceGrant{Policy: policy, Amount: 1, MaximumBonus: 2})
|
||
}
|
||
useLimit := definition.TotalUsableCount
|
||
if useLimit < 0 || useLimit > 2 {
|
||
return nil, fmt.Errorf("tower recommendation daily use count=%d exceeds supported maximum 2", useLimit)
|
||
}
|
||
if useLimit > 0 {
|
||
ticket.UseLimit = dnfdungeonadmission.AllowanceUseLimit{
|
||
Key: fmt.Sprintf("%s.recommendation.item.%d.daily", prefix, definition.ItemID), Limit: uint32(useLimit),
|
||
}
|
||
}
|
||
}
|
||
return ticket, nil
|
||
}
|
||
|
||
func dungeonAdmissionPolicyContains(policy dnfdungeonadmission.Policy, dungeonID uint32) bool {
|
||
for _, candidate := range policy.DungeonIDs {
|
||
if candidate == dungeonID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func normalizeCustomAbilityGroupConverterAction(value string) bool {
|
||
return strings.EqualFold(
|
||
strings.TrimSpace(strings.ReplaceAll(value, "`", "")),
|
||
"[custom ability set group]",
|
||
)
|
||
}
|
||
|
||
func normalizeAvatarFittingRoomTicketAction(value string) bool {
|
||
return strings.EqualFold(
|
||
strings.TrimSpace(strings.ReplaceAll(value, "`", "")),
|
||
"[avatar fitting room ticket]",
|
||
)
|
||
}
|
||
|
||
func currentPVFAvatarFittingRoomTicket(source dnfpvf.Source, definition dungeonDropItemDefinition) (bool, error) {
|
||
if normalizeAvatarFittingRoomTicketAction(definition.ActionType) {
|
||
return true, nil
|
||
}
|
||
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" {
|
||
return false, nil
|
||
}
|
||
if source == nil || strings.TrimSpace(definition.PVFPath) == "" {
|
||
return false, nil
|
||
}
|
||
text, err := source.ReadText(definition.PVFPath)
|
||
if err != nil {
|
||
return false, fmt.Errorf("read avatar-fitting ticket %s: %w", definition.PVFPath, err)
|
||
}
|
||
document, err := dnfpvf.Parse(definition.PVFPath, text)
|
||
if err != nil {
|
||
return false, fmt.Errorf("parse avatar-fitting ticket %s: %w", definition.PVFPath, err)
|
||
}
|
||
_, found := document.Section("avatar fitting room ticket")
|
||
return found, nil
|
||
}
|
||
|
||
func currentPVFTransformationUseResolution(definition dungeonDropItemDefinition) (*alignedcmd.TransformationUseResolution, error) {
|
||
stackableType := normalizeDungeonDropStackableType(definition.StackableType)
|
||
actionType := normalizeDungeonDropStackableType(definition.ActionType)
|
||
transformation := &alignedcmd.TransformationUseResolution{
|
||
VillageAllowed: definition.ActionUsableVillage,
|
||
DungeonAllowed: definition.ActionUsableDungeon,
|
||
SeriaRoomAllowed: definition.ActionUsableSeriaRoom,
|
||
DisallowAssault: definition.ActionDisallowAssault,
|
||
ActionUsableDungeonIDs: append([]int64(nil), definition.ActionUsableDungeonIDs...),
|
||
LimitDungeonIDs: append([]int64(nil), definition.LimitDungeonIDs...),
|
||
}
|
||
hasExplicitPlace := transformation.VillageAllowed || transformation.DungeonAllowed || transformation.SeriaRoomAllowed
|
||
|
||
switch {
|
||
case stackableType == "[disguise]" || stackableType == "[random disguise]":
|
||
transformation.ActionType = stackableType
|
||
transformation.FormKinds = append([]string(nil), definition.ActionStringData...)
|
||
if len(transformation.FormKinds) == 0 || len(definition.ActionIntData) < 2 || len(definition.ActionIntData)%2 != 0 {
|
||
return nil, fmt.Errorf("%s requires form kind(s) and (form,duration) pairs: strings=%v ints=%v", stackableType, definition.ActionStringData, definition.ActionIntData)
|
||
}
|
||
for index := 0; index < len(definition.ActionIntData); index += 2 {
|
||
formID := definition.ActionIntData[index]
|
||
durationSeconds := definition.ActionIntData[index+1]
|
||
if formID <= 0 || durationSeconds <= 0 || durationSeconds > int64(^uint32(0)) {
|
||
return nil, fmt.Errorf("%s invalid form/duration pair (%d,%d)", stackableType, formID, durationSeconds)
|
||
}
|
||
transformation.FormIDs = append(transformation.FormIDs, formID)
|
||
if transformation.DurationSeconds == 0 {
|
||
transformation.DurationSeconds = durationSeconds
|
||
}
|
||
}
|
||
if !hasExplicitPlace {
|
||
transformation.VillageAllowed = true
|
||
transformation.SeriaRoomAllowed = true
|
||
}
|
||
case actionType == "[riding]":
|
||
if stackableType != "[waste]" || definition.ActionValues == nil || len(definition.ActionValues.Values) != 2 ||
|
||
definition.ActionValues.Values[0] <= 0 || definition.ActionValues.Values[1] <= 0 ||
|
||
definition.ActionValues.Values[1] > int64(^uint32(0)) {
|
||
return nil, fmt.Errorf("[riding] requires [waste] and positive (form,duration_ms): type=%q values=%v", definition.StackableType, definition.ActionValues)
|
||
}
|
||
transformation.ActionType = actionType
|
||
transformation.FormIDs = []int64{definition.ActionValues.Values[0]}
|
||
transformation.DurationSeconds = (definition.ActionValues.Values[1] + 999) / 1000
|
||
if !hasExplicitPlace {
|
||
transformation.DungeonAllowed = true
|
||
}
|
||
case actionType == "[ridable item]":
|
||
if stackableType != "[waste]" || definition.ActionValues == nil || len(definition.ActionValues.Values) == 0 || definition.ActionValues.Values[0] <= 0 {
|
||
return nil, fmt.Errorf("[ridable item] requires [waste] and a positive form: type=%q values=%v", definition.StackableType, definition.ActionValues)
|
||
}
|
||
transformation.ActionType = actionType
|
||
transformation.FormIDs = []int64{definition.ActionValues.Values[0]}
|
||
if !hasExplicitPlace {
|
||
transformation.DungeonAllowed = true
|
||
}
|
||
case actionType == "[celebrate big transition]":
|
||
if stackableType != "[etc]" || definition.ActionValues == nil || len(definition.ActionValues.Values) == 0 || len(definition.ActionIntData) == 0 || definition.ActionIntData[0] <= 0 {
|
||
return nil, fmt.Errorf("[celebrate big transition] requires [etc], form list and duration: type=%q values=%v ints=%v", definition.StackableType, definition.ActionValues, definition.ActionIntData)
|
||
}
|
||
for _, formID := range definition.ActionValues.Values {
|
||
if formID <= 0 {
|
||
return nil, fmt.Errorf("[celebrate big transition] invalid form id %d", formID)
|
||
}
|
||
}
|
||
transformation.ActionType = actionType
|
||
transformation.FormIDs = append([]int64(nil), definition.ActionValues.Values...)
|
||
transformation.DurationSeconds = definition.ActionIntData[0]
|
||
if !hasExplicitPlace {
|
||
transformation.VillageAllowed = true
|
||
transformation.SeriaRoomAllowed = true
|
||
}
|
||
default:
|
||
return nil, nil
|
||
}
|
||
return transformation, nil
|
||
}
|
||
|
||
func currentPVFProjectileUseResolution(definition dungeonDropItemDefinition) (*alignedcmd.ProjectileUseResolution, error) {
|
||
stackableType := normalizeDungeonDropStackableType(definition.StackableType)
|
||
if stackableType != "[throw]" && stackableType != "[set]" {
|
||
return nil, nil
|
||
}
|
||
if len(definition.ActionIntData) == 0 {
|
||
return nil, fmt.Errorf("%s requires a passive-object/effect id: %v", stackableType, definition.ActionIntData)
|
||
}
|
||
if definition.ActionIntData[0] <= 0 {
|
||
// The active PVF contains one APC-only dummy throw whose documented
|
||
// effect id is zero. It intentionally plays only a motion and must not
|
||
// become a player-consumable server action.
|
||
return nil, nil
|
||
}
|
||
for _, value := range definition.ActionIntData {
|
||
if value < 0 {
|
||
return nil, fmt.Errorf("%s has a negative int-data operand: %v", stackableType, definition.ActionIntData)
|
||
}
|
||
}
|
||
projectile := &alignedcmd.ProjectileUseResolution{
|
||
StackableType: stackableType,
|
||
EffectID: definition.ActionIntData[0],
|
||
IntData: append([]int64(nil), definition.ActionIntData...),
|
||
ParticlePaths: append([]string(nil), definition.ActionStringData...),
|
||
DungeonAllowed: definition.ActionUsableDungeon,
|
||
ActionUsableDungeonIDs: append([]int64(nil), definition.ActionUsableDungeonIDs...),
|
||
LimitDungeonIDs: append([]int64(nil), definition.LimitDungeonIDs...),
|
||
}
|
||
if !definition.ActionUsableVillage && !definition.ActionUsableDungeon && !definition.ActionUsableSeriaRoom {
|
||
projectile.DungeonAllowed = true
|
||
}
|
||
return projectile, nil
|
||
}
|
||
|
||
func currentPVFSkillPointBook(pvfPath string) (alignedcmd.TPBookResolution, bool) {
|
||
normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(pvfPath, "\\", "/")))
|
||
switch normalized {
|
||
case "stackable/book_skill1.stk", "stackable/extention/test_book_skill1.stk":
|
||
return alignedcmd.TPBookResolution{Kind: alignedcmd.SkillPointBookAddSP, Delta: 5}, true
|
||
case "stackable/book_skill2.stk":
|
||
return alignedcmd.TPBookResolution{Kind: alignedcmd.SkillPointBookAddSP, Delta: 20}, true
|
||
case "stackable/book_fskill1.stk":
|
||
return alignedcmd.TPBookResolution{Kind: alignedcmd.SkillPointBookAddTP, Delta: 1}, true
|
||
case "stackable/book_fskill2.stk":
|
||
return alignedcmd.TPBookResolution{Kind: alignedcmd.SkillPointBookAddTP, Delta: 5}, true
|
||
case "stackable/book_fskillreset.stk", "stackable/book_fskillreset_event.stk":
|
||
return alignedcmd.TPBookResolution{Kind: alignedcmd.SkillPointBookResetTP}, true
|
||
default:
|
||
return alignedcmd.TPBookResolution{}, false
|
||
}
|
||
}
|
||
|
||
// currentPVFCharacterExperienceBook covers the six numbered character
|
||
// experience books. Unlike every growth capsule they declare no
|
||
// [increase status type], no action type and no operand at all: the active PVF
|
||
// entry is a bare [etc] stackable whose only numeric statement is its
|
||
// localized name. The amount therefore cannot be read out of the document and
|
||
// has to be declared here, keyed by PVF path like currentPVFSkillPointBook.
|
||
//
|
||
// Deliberately absent, and refused by the ordinary bare-[etc] fallthrough: the
|
||
// duel experience books (1121-1123) and the guild experience books
|
||
// (1200-1202), which spend separate experience currencies this server keeps no
|
||
// ledger for. The TP reset book is covered by currentPVFSkillPointBook.
|
||
func currentPVFCharacterExperienceBook(pvfPath string) (int64, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(strings.ReplaceAll(pvfPath, "\\", "/"))) {
|
||
case "stackable/book_exp1.stk":
|
||
return 100, true
|
||
case "stackable/book_exp2.stk":
|
||
return 1000, true
|
||
case "stackable/book_exp3.stk":
|
||
return 10000, true
|
||
case "stackable/book_exp4.stk":
|
||
return 100000, true
|
||
case "stackable/book_exp5.stk":
|
||
return 1000000, true
|
||
case "stackable/book_exp6.stk":
|
||
return 10000000, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
// currentPVFCharacterExperienceBookName is the name statement the declared
|
||
// amount has to reproduce. The books carry their own amount in their localized
|
||
// name, so this is what keeps the table above honest: a wrong number stops
|
||
// matching the runtime document and the resolution fails instead of granting
|
||
// experience nobody wrote down.
|
||
func currentPVFCharacterExperienceBookName(amount int64) string {
|
||
return fmt.Sprintf("增加%d经验值", amount)
|
||
}
|
||
|
||
// auditCurrentPVFCharacterExperienceBook validates one table row against the
|
||
// runtime document. The table is only a fallback for a document that states
|
||
// nothing, so any declared effect on the entry is an error rather than
|
||
// something to shadow.
|
||
func auditCurrentPVFCharacterExperienceBook(definition dungeonDropItemDefinition, amount int64) error {
|
||
if stackableType := normalizeDungeonDropStackableType(definition.StackableType); stackableType != "[etc]" {
|
||
return fmt.Errorf("expected a bare [etc] stackable, got type=%q", definition.StackableType)
|
||
}
|
||
if actionType := strings.TrimSpace(strings.ReplaceAll(definition.ActionType, "`", "")); actionType != "" {
|
||
return fmt.Errorf("entry declares action type %q, so the declared amount would shadow it", actionType)
|
||
}
|
||
if effectType := strings.TrimSpace(strings.ReplaceAll(definition.DirectExperienceType, "`", "")); effectType != "" {
|
||
return fmt.Errorf("entry declares increase status type %q, so the declared amount would shadow it", effectType)
|
||
}
|
||
if amount <= 0 || amount > int64(^uint32(0)) {
|
||
return fmt.Errorf("declared amount %d is out of range", amount)
|
||
}
|
||
want := currentPVFCharacterExperienceBookName(amount)
|
||
if !strings.HasPrefix(strings.TrimSpace(definition.Name), want) {
|
||
return fmt.Errorf("name %q does not begin with %q for the declared amount %d", definition.Name, want, amount)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func rollLevelUpTicketRange(definition dungeonDropItemDefinition) (int64, error) {
|
||
if definition.LevelUpRange <= 0 {
|
||
return 0, fmt.Errorf("levelup range %d is invalid", definition.LevelUpRange)
|
||
}
|
||
if len(definition.LevelUpOutcomes) == 0 {
|
||
return definition.LevelUpRange, nil
|
||
}
|
||
var total int64
|
||
for _, outcome := range definition.LevelUpOutcomes {
|
||
if outcome.Range <= 0 || outcome.Range > definition.LevelUpRange || outcome.Weight <= 0 ||
|
||
total > int64(^uint64(0)>>1)-outcome.Weight {
|
||
return 0, fmt.Errorf("invalid weighted outcome %+v", outcome)
|
||
}
|
||
total += outcome.Weight
|
||
}
|
||
roll, err := secureCurrentBoosterRoll(total)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
for _, outcome := range definition.LevelUpOutcomes {
|
||
if roll < outcome.Weight {
|
||
return outcome.Range, nil
|
||
}
|
||
roll -= outcome.Weight
|
||
}
|
||
return 0, fmt.Errorf("weighted outcome total %d did not resolve", total)
|
||
}
|
||
|
||
func normalizeLevelUpTicketAction(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
|
||
if value == "[level up ticket]" {
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeStoryBookAction(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
|
||
if value == "[story book]" {
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeDirectExperienceType(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
|
||
switch value {
|
||
case "[expup]", "[exppercentup]", "[expupbypercent]", "[expupbytable]", "[levelupbytable]", "[level up ticket]":
|
||
// The active PVF uses both spellings for the same one-operand,
|
||
// percentage-of-current-level effect. Keep one canonical type so the
|
||
// transaction and its validation remain identical for every capsule.
|
||
if value == "[expupbypercent]" {
|
||
return "[exppercentup]"
|
||
}
|
||
return value
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func validateDirectExperienceValues(effectType string, values []int64) error {
|
||
switch effectType {
|
||
case "[expup]":
|
||
if len(values) != 1 || values[0] <= 0 || values[0] > int64(^uint32(0)) {
|
||
return fmt.Errorf("invalid expUp operands %v", values)
|
||
}
|
||
case "[exppercentup]":
|
||
if len(values) != 1 || values[0] <= 0 || values[0] > 100 {
|
||
return fmt.Errorf("invalid expPercentUp operands %v", values)
|
||
}
|
||
case "[expupbytable]", "[levelupbytable]":
|
||
if len(values) == 0 || len(values)%3 != 0 {
|
||
return fmt.Errorf("invalid table operands %v", values)
|
||
}
|
||
for index := 0; index < len(values); index += 3 {
|
||
minimum, maximum, result := values[index], values[index+1], values[index+2]
|
||
if minimum <= 0 || maximum < minimum || result <= 0 ||
|
||
(effectType == "[expupbytable]" && result > int64(^uint32(0))) {
|
||
return fmt.Errorf("invalid table row %v", values[index:index+3])
|
||
}
|
||
}
|
||
default:
|
||
return fmt.Errorf("unsupported direct-experience type %q", effectType)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeQuestCompletionTicketAction(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
|
||
switch value {
|
||
case "[any quest clear]",
|
||
"[achievement quest clear]",
|
||
"[first awaken clear]",
|
||
"[second awaken clear]",
|
||
"[quest clear]",
|
||
"[quest clear coupon]",
|
||
"[lv clear]",
|
||
"[gm quest clear]",
|
||
"[side quest clear]":
|
||
return value
|
||
default:
|
||
return ""
|
||
}
|
||
}
|