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

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

842 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentUpgradeSuccessBase = 100000
currentUpgradeNoticeDefault = -1
currentUpgradePVFPath = "etc/upgrade.etc"
currentAmplifyUpgradePVFPath = "etc/amplifyupgrade.etc"
currentUpgradeTableShortRowWidth = 16
currentUpgradeTableFullRowWidth = 24
currentUpgradeTableTailWidth = 8
)
// currentUpgradeTableRow is one level row from [table type] in etc/upgrade.etc.
//
// The row's values[1] is deliberately not carried. It is a weight inside the
// success/penalty table, not the coefficient the client prices with; charging
// from it over-charged 增幅 by 4.5x (live 2026-09-25, see
// currentUpgradeTable.CostWeightByUpgradeLevel).
type currentUpgradeTableRow struct {
TargetLevel int
FailureWeight int // out of 100000
PenaltyType int // 0=no change, 1=one-level downgrade, 2=value downgrade, 3=destroy
PenaltyValue int
MaterialItemID int
MaterialCount int
}
func (r currentUpgradeTableRow) SuccessWeight() int {
w := currentUpgradeSuccessBase - r.FailureWeight
if w < 0 {
return 0
}
return w
}
// currentUpgradeTable is the parsed PVF upgrade/amplify configuration.
type currentUpgradeTable struct {
Rows []currentUpgradeTableRow
NoticeLevel int
Costs []int
CostWeightsByRarity []float64
TypeWeights []float64
// CostWeightByUpgradeLevel is [cost weight by upgrade level], indexed by the
// *current* upgrade level (0-based). It is the client's own per-level gold
// coefficient, and the 强化器 / 增幅器 popup price is exactly
//
// [cost][grade] * [cost weights by rarity][rarity] * [type][slot] * this
//
// Empty means the file carries no such section: that is the 增幅 case
// (etc/amplifyupgrade.etc) and the client then prices at the base cost with
// no level factor at all.
CostWeightByUpgradeLevel []float64
DestroyLevelByRarity map[string]int
DisjointBonusItemID int
DisjointEquipLevelConst int
DisjointWeightByRarity map[string]float64
DisjointLevelConst int
// DisjointCorrectionGradeByRarity is upgrade.etc [disjoint] [correction
// grade by rarity]: the equipment grade is lowered by this much before it
// is compared with [equip level const].
DisjointCorrectionGradeByRarity map[string]float64
}
func (t *currentUpgradeTable) RowForLevel(level int) (currentUpgradeTableRow, bool) {
if level < 1 || level > len(t.Rows) {
return currentUpgradeTableRow{}, false
}
return t.Rows[level-1], true
}
// costWeightForCurrentLevel returns the client's gold coefficient for an attempt
// that starts at currentLevel (i.e. targetLevel-1).
//
// Three cases, each pinned by a live reading rather than by preference:
//
// - The section exists and covers the level: use it. etc/upgrade.etc carries
// 19 `level weight weight` entries for +0..+18, and they reproduce the 强化器
// popup at +0->+1 (0.50), +1->+2 (0.50) and +14->+15 (8.50).
// - The section is missing: coefficient 1, the bare base price. That is 增幅,
// whose etc/amplifyupgrade.etc has no such section; its popup quoted exactly
// 44100 * 2.50 * 2.00 = 220500 for a +10->+11 attempt while the old
// weight*25 term charged 992250.
// - The section exists but is unusable (not a clean level-0..n-1 triple run, or
// a non-positive weight): also 1. Never zero -- an unreadable PVF must not
// hand the attempt out for free.
//
// Levels past the section's last entry hold that last entry instead of dropping
// to 1: [max upgrade level by rarity] allows far beyond +18, and falling back
// would make +19 cheaper than +18.
func (t *currentUpgradeTable) costWeightForCurrentLevel(currentLevel int) float64 {
weights := t.CostWeightByUpgradeLevel
if len(weights) == 0 {
return 1
}
if currentLevel < 0 {
return weights[0]
}
if currentLevel >= len(weights) {
return weights[len(weights)-1]
}
return weights[currentLevel]
}
func (t *currentUpgradeTable) DestroyLevel(rarity int) int {
name := currentUpgradeRarityName(rarity)
if lv, ok := t.DestroyLevelByRarity[name]; ok {
return lv
}
return -1
}
var (
currentUpgradeTableMu sync.Mutex
currentUpgradeTableCache *currentUpgradeTable
currentUpgradeTableLoadErr error
currentAmplifyTableCache *currentUpgradeTable
currentAmplifyTableLoadErr error
)
var currentUpgradeTokenRE = regexp.MustCompile("`[^`]*`|-?\\d+(?:\\.\\d+)?")
func (s *Service) currentUpgradeTableForMode(mode string) (*currentUpgradeTable, error) {
currentUpgradeTableMu.Lock()
defer currentUpgradeTableMu.Unlock()
path := currentUpgradePVFPath
if mode == "amplify" {
if currentAmplifyTableCache != nil {
return currentAmplifyTableCache, nil
}
if currentAmplifyTableLoadErr != nil {
return nil, currentAmplifyTableLoadErr
}
path = currentAmplifyUpgradePVFPath
} else {
if currentUpgradeTableCache != nil {
return currentUpgradeTableCache, nil
}
if currentUpgradeTableLoadErr != nil {
return nil, currentUpgradeTableLoadErr
}
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
s.setCurrentUpgradeTableLoadErrLocked(mode, err)
s.logPacketEvent("dnf-upgrade-table-load-failed", "mode", mode, "path", path, "stage", "open_pvf", "error", err)
return nil, err
}
text, err := archive.ReadText(path)
if err != nil {
loadErr := fmt.Errorf("read %s: %w", path, err)
s.setCurrentUpgradeTableLoadErrLocked(mode, loadErr)
s.logPacketEvent("dnf-upgrade-table-load-failed", "mode", mode, "path", path, "stage", "read", "error", loadErr)
return nil, loadErr
}
doc, err := dnfpvf.Parse(path, text)
if err != nil {
loadErr := fmt.Errorf("parse %s: %w", path, err)
s.setCurrentUpgradeTableLoadErrLocked(mode, loadErr)
s.logPacketEvent("dnf-upgrade-table-load-failed", "mode", mode, "path", path, "stage", "parse", "error", loadErr)
return nil, loadErr
}
table := parseCurrentUpgradeTable(doc, text)
if mode == "amplify" {
currentAmplifyTableCache = table
} else {
currentUpgradeTableCache = table
}
s.logPacketEvent("dnf-upgrade-table-loaded", "mode", mode, "rows", len(table.Rows), "notice_level", table.NoticeLevel)
return table, nil
}
func (s *Service) setCurrentUpgradeTableLoadErrLocked(mode string, err error) {
if mode == "amplify" {
currentAmplifyTableLoadErr = err
return
}
currentUpgradeTableLoadErr = err
}
func parseCurrentUpgradeTable(doc *dnfpvf.Document, rawText string) *currentUpgradeTable {
table := &currentUpgradeTable{
NoticeLevel: currentUpgradeNoticeDefault,
DestroyLevelByRarity: make(map[string]int),
DisjointWeightByRarity: make(map[string]float64),
DisjointBonusItemID: -1,
DisjointEquipLevelConst: -1,
DisjointLevelConst: -1,
DisjointCorrectionGradeByRarity: make(map[string]float64),
}
// Parse [table] rows by their authoritative gameplay tail. The current
// runtime PVF is not the old fixed-17-column shape: target level +1 is a
// short row, later target levels are full rows, and both shapes end with
// failure/penalty controls plus material item/count.
tableTokens := extractCurrentUpgradeTableTokens(rawText, "table")
for offset := 0; offset < len(tableTokens); {
values, next, ok := nextCurrentUpgradeTableRowValues(tableTokens, offset)
if !ok {
break
}
tail := values[len(values)-currentUpgradeTableTailWidth:]
// values[1] is intentionally not read here. It was charged as the gold
// coefficient until 2026-09-25 and is not one: the 增幅器 popup quoted
// 220500 while the server took 992250, because values[1] (0.18) * 25 was
// multiplied onto a price the client never scaled. Gold now comes from
// [cost weight by upgrade level] only.
table.Rows = append(table.Rows, currentUpgradeTableRow{
TargetLevel: len(table.Rows) + 1,
FailureWeight: int(math.Round(tail[0])),
PenaltyType: int(math.Round(tail[2])),
PenaltyValue: int(math.Round(tail[3])),
MaterialItemID: int(math.Round(tail[6])),
MaterialCount: int(math.Round(tail[7])),
})
offset = next
}
// [notice]
if values := doc.Ints("notice"); len(values) > 0 {
table.NoticeLevel = int(values[0])
}
// [cost]
for _, v := range doc.Ints("cost") {
table.Costs = append(table.Costs, int(v))
}
// [cost weights by rarity]
table.CostWeightsByRarity = doc.Numbers("cost weights by rarity")
// [type]
table.TypeWeights = doc.Numbers("type")
// [cost weight by upgrade level]
table.CostWeightByUpgradeLevel = parseCurrentUpgradeCostWeightByUpgradeLevel(doc.Numbers("cost weight by upgrade level"))
// [destroy level by rarity]
parseCurrentUpgradeStringIntPairs(rawText, "destroy level by rarity", table.DestroyLevelByRarity)
// [disjoint] section
parseCurrentUpgradeDisjoint(rawText, table)
return table
}
// parseCurrentUpgradeCostWeightByUpgradeLevel folds [cost weight by upgrade
// level] into a level-indexed coefficient. The live section is a flat run of 19
// `level weight weight` triples covering levels 0..18, and the two weights are
// equal in every entry, so the first is kept -- nothing in the file says which
// column is which mode. A run that does not start at level 0 and step by one, or
// that carries a non-positive weight, is rejected as a whole: silently keeping a
// shifted or zeroed row would move every level's price.
func parseCurrentUpgradeCostWeightByUpgradeLevel(values []float64) []float64 {
if len(values) == 0 || len(values)%3 != 0 {
return nil
}
weights := make([]float64, 0, len(values)/3)
for i := 0; i+2 < len(values); i += 3 {
if int(math.Round(values[i])) != len(weights) {
return nil
}
weight := values[i+1]
if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
return nil
}
weights = append(weights, weight)
}
return weights
}
func nextCurrentUpgradeTableRowValues(tokens []float64, offset int) ([]float64, int, bool) {
for _, width := range []int{currentUpgradeTableShortRowWidth, currentUpgradeTableFullRowWidth} {
end := offset + width
if end > len(tokens) {
continue
}
tailStart := end - currentUpgradeTableTailWidth
if !isCurrentUpgradeTableTail(tokens[tailStart:end]) {
continue
}
return tokens[offset:end], end, true
}
return nil, offset, false
}
func isCurrentUpgradeTableTail(values []float64) bool {
if len(values) != currentUpgradeTableTailWidth {
return false
}
itemID := int(math.Round(values[6]))
count := int(math.Round(values[7]))
if math.Abs(values[6]-float64(itemID)) > 0.0001 || math.Abs(values[7]-float64(count)) > 0.0001 {
return false
}
return itemID >= 1000 && count > 0
}
func parseCurrentUpgradeDisjoint(rawText string, table *currentUpgradeTable) {
lower := strings.ToLower(rawText)
idx := strings.Index(lower, "[disjoint bonus item]")
if idx >= 0 {
after := rawText[idx+len("[disjoint bonus item]"):]
tokens := currentUpgradeTokenRE.FindAllString(after, 1)
if len(tokens) > 0 {
if v, err := strconv.Atoi(strings.TrimSpace(tokens[0])); err == nil {
table.DisjointBonusItemID = v
}
}
}
idx = strings.Index(lower, "[equip level const]")
if idx >= 0 {
after := rawText[idx+len("[equip level const]"):]
tokens := currentUpgradeTokenRE.FindAllString(after, 1)
if len(tokens) > 0 {
if v, err := strconv.Atoi(strings.TrimSpace(tokens[0])); err == nil {
table.DisjointEquipLevelConst = v
}
}
}
idx = strings.Index(lower, "[upgrade const for bonus item count]")
if idx >= 0 {
after := rawText[idx+len("[upgrade const for bonus item count]"):]
tokens := currentUpgradeTokenRE.FindAllString(after, 1)
if len(tokens) > 0 {
if v, err := strconv.Atoi(strings.TrimSpace(tokens[0])); err == nil {
table.DisjointLevelConst = v
}
}
}
parseCurrentUpgradeStringFloatPairs(rawText, "upgrade failed bonus weight by rarity", table.DisjointWeightByRarity)
if table.DisjointCorrectionGradeByRarity == nil {
table.DisjointCorrectionGradeByRarity = make(map[string]float64)
}
parseCurrentUpgradeStringFloatPairs(rawText, "correction grade by rarity", table.DisjointCorrectionGradeByRarity)
}
// extractCurrentUpgradeTableTokens reads the numeric values inside [table]...[/table],
// skipping the first token if it's a string (table type name like `normal`).
func extractCurrentUpgradeTableTokens(rawText, section string) []float64 {
lower := strings.ToLower(rawText)
startTag := "[" + section + "]"
endTag := "[/" + section + "]"
startIdx := strings.Index(lower, startTag)
if startIdx < 0 {
return nil
}
startIdx += len(startTag)
endIdx := strings.Index(lower[startIdx:], endTag)
var content string
if endIdx >= 0 {
content = rawText[startIdx : startIdx+endIdx]
} else {
content = rawText[startIdx:]
}
// Remove nested section tags like [table type]
content = regexp.MustCompile(`(?i)\[/?table[^\]]*\]`).ReplaceAllString(content, " ")
tokens := currentUpgradeTokenRE.FindAllString(content, -1)
var values []float64
for _, token := range tokens {
if strings.HasPrefix(token, "`") {
continue // skip string tokens (table type name)
}
if v, err := strconv.ParseFloat(token, 64); err == nil {
values = append(values, v)
}
}
return values
}
func parseCurrentUpgradeStringIntPairs(rawText, section string, out map[string]int) {
lower := strings.ToLower(rawText)
startTag := "[" + section + "]"
endTag := "[/" + section + "]"
startIdx := strings.Index(lower, startTag)
if startIdx < 0 {
return
}
startIdx += len(startTag)
endIdx := strings.Index(lower[startIdx:], endTag)
var content string
if endIdx >= 0 {
content = rawText[startIdx : startIdx+endIdx]
} else {
content = rawText[startIdx:]
}
tokens := currentUpgradeTokenRE.FindAllString(content, -1)
for i := 0; i+1 < len(tokens); i += 2 {
name := strings.Trim(tokens[i], "` ")
if v, err := strconv.ParseFloat(tokens[i+1], 64); err == nil {
out[strings.ToLower(name)] = int(math.Round(v))
}
}
}
func parseCurrentUpgradeStringFloatPairs(rawText, section string, out map[string]float64) {
lower := strings.ToLower(rawText)
startTag := "[" + section + "]"
endTag := "[/" + section + "]"
startIdx := strings.Index(lower, startTag)
if startIdx < 0 {
return
}
startIdx += len(startTag)
endIdx := strings.Index(lower[startIdx:], endTag)
var content string
if endIdx >= 0 {
content = rawText[startIdx : startIdx+endIdx]
} else {
content = rawText[startIdx:]
}
tokens := currentUpgradeTokenRE.FindAllString(content, -1)
for i := 0; i+1 < len(tokens); i += 2 {
name := strings.Trim(tokens[i], "` ")
if v, err := strconv.ParseFloat(tokens[i+1], 64); err == nil {
out[strings.ToLower(name)] = v
}
}
}
func currentUpgradeRarityName(rarity int) string {
switch rarity {
case 0:
return "common"
case 1:
return "uncommon"
case 2:
return "rare"
case 3:
return "unique"
case 4:
return "epic"
case 5:
return "chronicle"
case 6:
return "legendary"
default:
return ""
}
}
// alignedUpgradePolicyResolver returns a closure that resolves the PVF upgrade
// table row for a given mode and current level, providing success weight and
// penalty type to the inventory owner's RNG path.
func (s *Service) alignedUpgradePolicyResolver() func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
return func(mode string, currentLevel int, targetItemID int64, amplifyType int) (alignedcmd.UpgradePolicyResolution, error) {
table, err := s.currentUpgradeTableForMode(mode)
if err != nil {
s.logPacketEvent("dnf-upgrade-policy-resolve-failed",
"mode", mode,
"current_level", currentLevel,
"error", err)
return alignedcmd.UpgradePolicyResolution{}, err
}
goldCost, err := s.currentUpgradeGoldCost(table, targetItemID, currentLevel+1)
if err != nil {
s.logPacketEvent("dnf-upgrade-policy-resolve-failed",
"mode", mode,
"current_level", currentLevel,
"target_item_id", targetItemID,
"error", err)
return alignedcmd.UpgradePolicyResolution{}, err
}
targetLevel := currentLevel + 1
row, ok := table.RowForLevel(targetLevel)
if !ok {
// Beyond table range: guaranteed success (no penalty data).
s.logPacketEvent("dnf-upgrade-policy-resolved",
"mode", mode,
"current_level", currentLevel,
"target_level", targetLevel,
"table_rows", len(table.Rows),
"material_item_id", 0,
"material_count", 0,
"reason", "beyond_table_range")
return alignedcmd.UpgradePolicyResolution{SuccessWeight: currentUpgradeSuccessBase, GoldCost: goldCost}, nil
}
penaltyType, penaltyValue := currentUpgradePenaltyForAttempt(mode, currentLevel, row, table)
resolution := alignedcmd.UpgradePolicyResolution{
SuccessWeight: row.SuccessWeight(),
PenaltyType: penaltyType,
PenaltyValue: penaltyValue,
GoldCost: goldCost,
MaterialItemID: row.MaterialItemID,
MaterialCount: row.MaterialCount,
NoticeLevel: table.NoticeLevel,
}
rarity, grade := -1, -1
if penaltyType == 3 {
if definition, ok := s.currentUpgradeTargetDefinition(targetItemID); ok {
rarity = int(definition.Rarity)
grade = int(definition.Grade)
if grade <= 0 {
grade = int(definition.MinimumLevel)
}
if table.DisjointBonusItemID > 0 {
if count := currentUpgradeDestroyBonusCount(table, currentLevel, rarity, grade); count > 0 {
resolution.DestroyBonusItemID = table.DisjointBonusItemID
resolution.DestroyBonusCount = count
}
}
resolution.DestroyExtraRewards = currentUpgradeDestroyExtraRewards(definition, s.currentDisjointerRulesForDisjoint(nil))
// 增幅 destruction refunds both traces. 86JP's
// CalculateDestroyBonuses (ItemUpgradeTableProvider.cs:88-97)
// runs the amplify table and then the reinforce table and
// returns whichever of the two produced a count, so a
// destroyed amplified item yields amplifyupgrade.etc's bonus
// item (3244 in the live PVF) *and* upgrade.etc's (3243).
// This tree only ever computed the requested mode's table.
if mode == "amplify" {
if extra, ok := s.currentUpgradeAmplifyCompanionRefund(currentLevel, rarity, grade); ok {
resolution.DestroyExtraRewards = append(resolution.DestroyExtraRewards, extra)
}
}
// House rule, deliberately beyond the PVF and 86JP: a target
// destroyed by reinforcement or amplification also refunds
// what it would have yielded in the 分解机. upgrade.etc has no
// such table -- its only destruction payout is the [disjoint
// bonus item] trace -- so 无色小晶块 and 灵魂 can only come
// from the disassembly tables (etc/disjoint.etc plus
// disjointer.exj), which is what this reuses.
resolution.DestroyExtraRewards = append(resolution.DestroyExtraRewards,
s.currentUpgradeDestroyDisjointRewards(definition, targetItemID, amplifyType)...)
}
}
s.logPacketEvent("dnf-upgrade-policy-resolved",
"mode", mode,
"current_level", currentLevel,
"target_level", targetLevel,
"table_rows", len(table.Rows),
"success_weight", resolution.SuccessWeight,
"penalty_type", resolution.PenaltyType,
"penalty_value", resolution.PenaltyValue,
"gold_cost", resolution.GoldCost,
"material_item_id", resolution.MaterialItemID,
"material_count", resolution.MaterialCount,
// Destruction refunds are entirely driven by upgrade.etc
// [disjoint]; without the rarity and grade that fed it there is no
// way to tell "the table says zero" from "we dropped the reward".
"target_rarity", rarity,
"target_grade", grade,
"destroy_bonus_item_id", resolution.DestroyBonusItemID,
"destroy_bonus_count", resolution.DestroyBonusCount,
"destroy_extra_rewards", len(resolution.DestroyExtraRewards))
return resolution, nil
}
}
// currentUpgradeGoldCost applies the active upgrade/amplify table's native
// price for one attempt: grade base cost * rarity weight * equipment-type
// weight * the coefficient for the *current* level. targetLevel is the level
// being bought, i.e. currentLevel+1, so the coefficient is looked up at
// targetLevel-1. ItemStack.Extra is deliberately not consulted because ordinary
// persisted equipment rows do not carry these global PVF values.
func (s *Service) currentUpgradeGoldCost(table *currentUpgradeTable, targetItemID int64, targetLevel int) (int64, error) {
if table == nil {
return 0, fmt.Errorf("upgrade gold cost table is missing")
}
if targetItemID <= 0 || targetItemID > math.MaxUint32 {
return 0, fmt.Errorf("upgrade gold cost target item is invalid: %d", targetItemID)
}
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return 0, fmt.Errorf("upgrade gold cost item catalog: %w", err)
}
definition, err := catalog.ResolveItem(uint32(targetItemID))
if err != nil {
return 0, fmt.Errorf("upgrade gold cost target item=%d: %w", targetItemID, err)
}
if definition.Kind != dungeonDropItemEquipment {
return 0, fmt.Errorf("upgrade gold cost target item=%d kind=%s is not equipment", targetItemID, definition.Kind)
}
return calculateCurrentUpgradeGoldCost(table, definition, targetLevel)
}
// calculateCurrentUpgradeGoldCost reproduces the 强化器 / 增幅器 popup:
//
// [cost][grade] * [cost weights by rarity][rarity] * [type][equipment type]
// * [cost weight by upgrade level][currentLevel]
//
// The last factor is the client's own, and it is the one that keeps going wrong:
//
// - Before 2026-09-11 it was dropped entirely, so every level of a piece cost
// the same.
// - From 2026-09-11 it was replaced by [table type] values[1] * 25, an
// empirical constant fitted to three 强化 readings (2124/2124/199422/3390174,
// see TestCurrentUpgradeGoldCostMatchesTheClientPopup). Those readings all
// sit on levels where the fitted curve happens to equal the client's, so the
// fit looked right while being wrong in between: it charged 2x at +2->+3 and
// 3x at +10->+11.
// - On 2026-09-25 the 增幅器 quoted 220500 for a +10->+11 attempt while the
// server took 992250 -- exactly 4.5x, the fitted factor. 增幅 has no
// [cost weight by upgrade level] section at all, so its coefficient is 1 and
// the popup is the bare base price.
func calculateCurrentUpgradeGoldCost(table *currentUpgradeTable, definition dungeonDropItemDefinition, targetLevel int) (int64, error) {
if table == nil {
return 0, fmt.Errorf("upgrade gold cost table is missing")
}
grade := int(definition.Grade)
if grade < 0 || grade >= len(table.Costs) {
return 0, fmt.Errorf("upgrade gold cost target item=%d grade=%d outside cost rows=%d", definition.ItemID, grade, len(table.Costs))
}
// A zero base cost is never a real price, it is a grade the table does not
// cover -- including grade 0, which is what a definition that only carries
// [minimum level] looks like. Fail closed instead of handing out a free
// upgrade.
if table.Costs[grade] <= 0 {
return 0, fmt.Errorf("upgrade gold cost target item=%d grade=%d has no positive base cost", definition.ItemID, grade)
}
rarity := int(definition.Rarity)
if rarity < 0 || rarity >= len(table.CostWeightsByRarity) || table.CostWeightsByRarity[rarity] <= 0 {
return 0, fmt.Errorf("upgrade gold cost target item=%d rarity=%d has no positive weight", definition.ItemID, rarity)
}
typeIndex, ok := currentUpgradeEquipmentTypeIndex(definition.EquipmentType)
if !ok || typeIndex >= len(table.TypeWeights) || table.TypeWeights[typeIndex] <= 0 {
return 0, fmt.Errorf("upgrade gold cost target item=%d equipment_type=%q has no positive weight", definition.ItemID, definition.EquipmentType)
}
if targetLevel < 1 || targetLevel > len(table.Rows) {
return 0, fmt.Errorf("upgrade gold cost target item=%d target_level=%d outside table rows=%d", definition.ItemID, targetLevel, len(table.Rows))
}
raw := float64(table.Costs[grade]) * table.CostWeightsByRarity[rarity] * table.TypeWeights[typeIndex] *
table.costWeightForCurrentLevel(targetLevel-1)
if raw < 0 || math.IsNaN(raw) || math.IsInf(raw, 0) || raw > math.MaxInt64 {
return 0, fmt.Errorf("upgrade gold cost target item=%d produced invalid value=%v", definition.ItemID, raw)
}
return int64(math.Round(raw)), nil
}
func currentUpgradeEquipmentTypeIndex(raw string) (int, bool) {
switch normalizeEquipmentPlacementPVFType(raw) {
case "[weapon]":
return 0, true
case "[title name]":
return 1, true
case "[coat]":
return 2, true
case "[pants]":
return 3, true
case "[shoulder]":
return 4, true
case "[waist]":
return 5, true
case "[shoes]":
return 6, true
case "[amulet]":
return 7, true
case "[wrist]":
return 8, true
case "[ring]":
return 9, true
case "[support]":
return 10, true
case "[magic stone]":
return 11, true
case "[earring]":
return 12, true
default:
return 0, false
}
}
// currentUpgradePenaltyForAttempt keeps the PVF-authored success probability
// while applying the service's shared reinforcement/amplification protection
// boundary. A failed attempt from +7 through +9 drops exactly one level; a
// failed +10 -> +11 or higher attempt is destructive. In particular, the
// active amplifyupgrade.etc type-2 values 7/8/9 are absolute fallback levels
// in the client table, not quantities to subtract from the current level.
// Passing those values to the inventory owner's quantity-based type-2 branch
// reset +7/+8/+9 amplification to zero, while applying the table's common
// destroy threshold made +8/+9 failures destroy the equipment. Both outcomes
// violate the current service boundary and bypass its intrinsic protection.
func currentUpgradePenaltyForAttempt(mode string, currentLevel int, row currentUpgradeTableRow, table *currentUpgradeTable) (int, int) {
if mode == "reinforce" || mode == "amplify" {
switch {
case currentLevel >= 10:
return 3, 0
case currentLevel >= 7:
return 1, 0
default:
return row.PenaltyType, row.PenaltyValue
}
}
return row.PenaltyType, row.PenaltyValue
}
// currentUpgradeTargetDefinition resolves the upgrade target's equipment
// definition; an unresolvable target grants no destruction rewards, like the
// references.
func (s *Service) currentUpgradeTargetDefinition(targetItemID int64) (dungeonDropItemDefinition, bool) {
if targetItemID <= 0 || targetItemID > math.MaxUint32 {
return dungeonDropItemDefinition{}, false
}
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return dungeonDropItemDefinition{}, false
}
definition, err := catalog.ResolveItem(uint32(targetItemID))
if err != nil || definition.Kind != dungeonDropItemEquipment {
return dungeonDropItemDefinition{}, false
}
return definition, true
}
// currentUpgradeDestroyExtraRewards lists the fixed rows a destroyed target
// refunds besides the 强烈之痕迹 compensation: 异界 (rarity 5) equipment refunds
// disjointer.exj [chronicle set disjoint result] (异次元碎片 ×20 in the live
// PVF) whenever reinforcement or amplification destroys it.
func currentUpgradeDestroyExtraRewards(definition dungeonDropItemDefinition, rules currentDisjointerRules) []alignedcmd.UpgradeDestroyReward {
if rules == nil || definition.Rarity != currentDisjointChronicleRarity {
return nil
}
itemID, count, ok := rules.ChronicleSetResult()
if !ok || itemID <= 0 || itemID > math.MaxInt32 || count <= 0 || count > math.MaxInt32 {
return nil
}
return []alignedcmd.UpgradeDestroyReward{{ItemID: int(itemID), Count: int(count)}}
}
// currentUpgradeDestroyDisjointRewards returns what the destroyed target would
// have produced in the 分解机, so a blown-up piece is not a total loss. This is
// a house rule: neither upgrade.etc nor 86JP refunds disassembly products on
// destruction, so the rows come from the ordinary disassembly resolver
// (etc/disjoint.etc for the no-element cube, the rarity additional row, the
// soul expansion and the exact-item rows; disjointer.exj for the 异界 tables
// and the 红字 otherverse bonus).
//
// amplifyType is reconstructed into a synthetic stack because that is the only
// field currentEquipmentDisjointRewardsForSource reads off the source, and it
// decides the otherverse bonus -- which is precisely the case that matters for
// 增幅, since amplification is what puts 红字 on the item.
//
// Any resolution failure yields no extra rows rather than failing the upgrade:
// the trace compensation and the destruction itself must still go through.
func (s *Service) currentUpgradeDestroyDisjointRewards(
definition dungeonDropItemDefinition,
targetItemID int64,
amplifyType int,
) []alignedcmd.UpgradeDestroyReward {
if targetItemID <= 0 || targetItemID > math.MaxUint32 {
return nil
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil || catalog.source == nil {
return nil
}
document, err := parseDungeonCardPVFDocument(catalog.source, definition.PVFPath)
if err != nil || currentDisjointDocumentIsAvatar(document) {
return nil
}
config, err := s.currentDisjointPVFConfigCached(catalog.source)
if err != nil {
return nil
}
source := dnfrepo.ItemStack{ItemID: targetItemID}
if amplifyType != 0 {
source.Extra = map[string]string{"amplify_type": strconv.Itoa(amplifyType)}
}
rewards, err := currentEquipmentDisjointRewardsForSource(
config, s.currentDisjointerRulesForDisjoint(nil), uint32(targetItemID), document, source)
if err != nil {
s.logPacketEvent("dnf-upgrade-destroy-disjoint-refund-failed",
"target_item_id", targetItemID, "amplify_type", amplifyType, "error", err)
return nil
}
converted := make([]alignedcmd.UpgradeDestroyReward, 0, len(rewards))
for _, reward := range rewards {
if reward.ItemID == 0 || reward.Count == 0 {
continue
}
converted = append(converted, alignedcmd.UpgradeDestroyReward{
ItemID: int(reward.ItemID),
Count: int(reward.Count),
// These rows are the house rule, not the PVF contract: a full bag
// must cost the player a refund row, never the attempt.
BestEffort: true,
})
}
return converted
}
// currentUpgradeAmplifyCompanionRefund resolves the reinforce table's
// destruction bonus so an amplified item that breaks refunds both traces, the
// way 86JP's CalculateDestroyBonuses does. An unreadable reinforce table is
// not fatal: the amplify refund still stands on its own.
func (s *Service) currentUpgradeAmplifyCompanionRefund(currentLevel, rarity, grade int) (alignedcmd.UpgradeDestroyReward, bool) {
table, err := s.currentUpgradeTableForMode("reinforce")
if err != nil || table == nil || table.DisjointBonusItemID <= 0 {
return alignedcmd.UpgradeDestroyReward{}, false
}
count := currentUpgradeDestroyBonusCount(table, currentLevel, rarity, grade)
if count <= 0 {
return alignedcmd.UpgradeDestroyReward{}, false
}
return alignedcmd.UpgradeDestroyReward{ItemID: table.DisjointBonusItemID, Count: count}, true
}
// currentUpgradeDestroyBonusCount calculates the 强烈之痕迹 compensation on
// destruction from upgrade.etc [disjoint] (86JP ItemUpgradeTableProvider.
// CalculateDestroyBonus, usdof upgrade_config.destroy.reward_count): the
// equipment grade minus [correction grade by rarity] must reach [equip level
// const], the failed level must exceed [upgrade const for bonus item count],
// and the count is (level - const)^2 * [upgrade failed bonus weight by
// rarity]. Zero means no compensation.
func currentUpgradeDestroyBonusCount(table *currentUpgradeTable, currentLevel, rarity, equipmentGrade int) int {
if table == nil || currentLevel <= 0 {
return 0
}
rarityName := currentUpgradeRarityName(rarity)
correction := 0.0
if value, ok := table.DisjointCorrectionGradeByRarity[rarityName]; ok {
correction = value
}
correctedGrade := float64(equipmentGrade) - correction
if correctedGrade < 0 {
correctedGrade = 0
}
if table.DisjointEquipLevelConst > 0 && correctedGrade < float64(table.DisjointEquipLevelConst) {
return 0
}
if table.DisjointLevelConst < 0 || currentLevel <= table.DisjointLevelConst {
return 0
}
weight, ok := table.DisjointWeightByRarity[rarityName]
if !ok || weight <= 0 {
return 0
}
diff := currentLevel - table.DisjointLevelConst
count := int(float64(diff*diff) * weight)
if count <= 0 {
return 0
}
return count
}