Files
90CNServer/server/internal/services/dnfbridge/item_upgrade_policy_test.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

240 lines
11 KiB
Go

package dnfbridge
import (
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestParseCurrentUpgradeTableKeepsFractionalCostWeights(t *testing.T) {
const text = "[cost]\n100\n[/cost]\n[cost weights by rarity]\n0.5 1.5 3 5 8\n[/cost weights by rarity]\n[type]\n1.2 0 0.7\n[/type]\n"
document, err := dnfpvf.Parse("etc/upgrade.etc", text)
if err != nil {
t.Fatal(err)
}
table := parseCurrentUpgradeTable(document, text)
if len(table.CostWeightsByRarity) != 5 || table.CostWeightsByRarity[0] != 0.5 || table.CostWeightsByRarity[4] != 8 {
t.Fatalf("rarity weights=%v", table.CostWeightsByRarity)
}
if len(table.TypeWeights) != 3 || table.TypeWeights[0] != 1.2 || table.TypeWeights[1] != 0 || table.TypeWeights[2] != 0.7 {
t.Fatalf("type weights=%v", table.TypeWeights)
}
}
func TestCalculateCurrentUpgradeGoldCostUsesGradeRarityTypeAndTargetLevel(t *testing.T) {
costs := make([]int, 91)
costs[85] = 53_715
base := currentUpgradeTable{
Costs: costs,
CostWeightsByRarity: []float64{0.5, 1.5, 3, 5, 8, 4, 6},
TypeWeights: []float64{1.2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
// The coefficients are [cost weight by upgrade level], indexed by the
// *current* level: 0.50 for +0, 1.00 for +1. These are the two levels
// where the live 强化 table and the retired [table type] weight * 25
// curve agree, so the expectations below did not move when the
// coefficient source was corrected.
CostWeightByUpgradeLevel: []float64{0.5, 1.0},
Rows: []currentUpgradeTableRow{
{TargetLevel: 1},
{TargetLevel: 2},
},
}
// The [cost] row is indexed by [grade], not [minimum level]: a definition
// carrying only MinimumLevel must not resolve.
weapon := dungeonDropItemDefinition{ItemID: 1, Kind: dungeonDropItemEquipment, Grade: 85, Rarity: 4, EquipmentType: "[weapon]"}
// 53715 * 8 * 1.2 * 0.02 * 25 = 257832
got, err := calculateCurrentUpgradeGoldCost(&base, weapon, 1)
if err != nil || got != 257_832 {
t.Fatalf("weapon gold cost=%d err=%v want=257832", got, err)
}
// Doubling the level coefficient doubles the price.
got, err = calculateCurrentUpgradeGoldCost(&base, weapon, 2)
if err != nil || got != 515_664 {
t.Fatalf("weapon +2 gold cost=%d err=%v want=515664", got, err)
}
coat := weapon
coat.EquipmentType = "[coat]"
// 53715 * 8 * 1 * 0.02 * 25 = 214860
got, err = calculateCurrentUpgradeGoldCost(&base, coat, 1)
if err != nil || got != 214_860 {
t.Fatalf("coat gold cost=%d err=%v want=214860", got, err)
}
// A target level the table does not cover must fail closed rather than
// silently price the attempt at zero.
if _, err := calculateCurrentUpgradeGoldCost(&base, weapon, 3); err == nil {
t.Fatal("target level beyond the table was accepted")
}
onlyMinimumLevel := dungeonDropItemDefinition{ItemID: 1, Kind: dungeonDropItemEquipment, MinimumLevel: 85, Rarity: 4, EquipmentType: "[weapon]"}
if _, err := calculateCurrentUpgradeGoldCost(&base, onlyMinimumLevel, 1); err == nil {
t.Fatal("grade 0 was accepted; the cost row must come from [grade]")
}
}
func TestCalculateCurrentAmplifyGoldCostUsesIndependentPVFWeights(t *testing.T) {
costs := make([]int, 91)
costs[85] = 60_000
// No CostWeightByUpgradeLevel: etc/amplifyupgrade.etc carries no
// [cost weight by upgrade level] section, so the client's 增幅器 prices at
// the bare base cost. The live reading on 2026-09-25 was 220500 for a
// +10->+11 attempt, which is exactly 44100 * 2.50 * 2.00 with no level
// factor; the retired [table type] weight * 25 term charged 992250 there.
table := currentUpgradeTable{
Costs: costs,
CostWeightsByRarity: []float64{0.25, 0.75, 1.5, 2.5, 4, 2, 3},
TypeWeights: []float64{2, 0, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7},
Rows: []currentUpgradeTableRow{{TargetLevel: 1}},
}
definition := dungeonDropItemDefinition{ItemID: 1, Kind: dungeonDropItemEquipment, Grade: 85, Rarity: 4, EquipmentType: "[earring]"}
// 60000 * 4 * 0.7 = 168000
got, err := calculateCurrentUpgradeGoldCost(&table, definition, 1)
if err != nil || got != 168_000 {
t.Fatalf("amplify gold cost=%d err=%v want=168000", got, err)
}
// An 增幅 has no level coefficient, so +10->+11 costs the same base price
// as +0->+1. That flat curve is the client's, not a dropped coefficient:
// TestRealPVFAmplifyGoldCostMatchesTheClientPopup pins it against the live
// PVF at the level the 2026-09-25 popup was read.
table.Rows = make([]currentUpgradeTableRow, 11)
for i := range table.Rows {
table.Rows[i] = currentUpgradeTableRow{TargetLevel: i + 1}
}
got, err = calculateCurrentUpgradeGoldCost(&table, definition, 11)
if err != nil || got != 168_000 {
t.Fatalf("amplify +10->+11 gold cost=%d err=%v want=168000", got, err)
}
}
// TestCalculateCurrentUpgradeGoldCostUsesTheClientsPerLevelWeight pins the
// divergence that the retired [table type] weight * 25 curve got wrong: at
// current +2 the client's [cost weight by upgrade level] is 0.50 while that
// curve produced 1.00, so the live 强化 charged twice the popup.
func TestCalculateCurrentUpgradeGoldCostUsesTheClientsPerLevelWeight(t *testing.T) {
costs := make([]int, 91)
costs[85] = 53_715
table := currentUpgradeTable{
Costs: costs,
CostWeightsByRarity: []float64{0.5, 1.5, 3, 5, 8, 4, 6},
TypeWeights: []float64{1.2, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
// The live upgrade.etc run: 0..3 -> 0.50, 10 -> 1.50, 14 -> 8.50.
CostWeightByUpgradeLevel: []float64{0.5, 0.5, 0.5, 0.5, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.5, 2.5, 4, 6, 8.5},
}
for i := 1; i <= len(table.CostWeightByUpgradeLevel); i++ {
table.Rows = append(table.Rows, currentUpgradeTableRow{TargetLevel: i})
}
weapon := dungeonDropItemDefinition{ItemID: 1, Kind: dungeonDropItemEquipment, Grade: 85, Rarity: 4, EquipmentType: "[weapon]"}
// base = 53715 * 8 * 1.2 = 515664
for _, testCase := range []struct {
name string
targetLevel int
want int64
}{
{name: "+0->+1 uses 0.50", targetLevel: 1, want: 257_832},
{name: "+2->+3 still uses 0.50", targetLevel: 3, want: 257_832},
{name: "+9->+10 uses 1.50", targetLevel: 10, want: 773_496},
{name: "+14->+15 uses 8.50", targetLevel: 15, want: 4_383_144},
} {
t.Run(testCase.name, func(t *testing.T) {
got, err := calculateCurrentUpgradeGoldCost(&table, weapon, testCase.targetLevel)
if err != nil || got != testCase.want {
t.Fatalf("gold cost=%d err=%v want=%d", got, err, testCase.want)
}
})
}
// Past the end of the client's table the last published coefficient is held
// rather than dropping to 1, which would make +19 cheaper than +18.
table.Rows = append(table.Rows, currentUpgradeTableRow{TargetLevel: 16})
got, err := calculateCurrentUpgradeGoldCost(&table, weapon, 16)
if err != nil || got != 4_383_144 {
t.Fatalf("gold cost past the last level=%d err=%v want=4383144", got, err)
}
}
// TestParseCurrentUpgradeCostWeightByUpgradeLevel pins the shape check: only a
// clean level-0..n-1 run of positive weights is accepted, so a shifted or
// zeroed section can never silently reprice every level.
func TestParseCurrentUpgradeCostWeightByUpgradeLevel(t *testing.T) {
live := []float64{
0, 0.50, 0.50, 1, 0.50, 0.50, 2, 0.50, 0.50, 3, 0.50, 0.50,
4, 1, 1, 5, 1.10, 1.10, 6, 1.20, 1.20, 7, 1.30, 1.30,
8, 1.40, 1.40, 9, 1.50, 1.50, 10, 1.50, 1.50, 11, 2.50, 2.50,
12, 4, 4, 13, 6, 6, 14, 8.50, 8.50, 15, 11.50, 11.50,
16, 15, 15, 17, 16, 16, 18, 16, 16,
}
got := parseCurrentUpgradeCostWeightByUpgradeLevel(live)
if len(got) != 19 || got[0] != 0.50 || got[10] != 1.50 || got[11] != 2.50 || got[14] != 8.50 || got[18] != 16 {
t.Fatalf("live section folded to %v", got)
}
for _, testCase := range []struct {
name string
values []float64
}{
{name: "absent", values: nil},
{name: "not triples", values: []float64{0, 0.5, 1, 0.5}},
{name: "level gap", values: []float64{0, 0.5, 0.5, 2, 0.5, 0.5}},
{name: "does not start at zero", values: []float64{1, 0.5, 0.5, 2, 0.5, 0.5}},
{name: "zero weight", values: []float64{0, 0.5, 0.5, 1, 0, 0}},
{name: "negative weight", values: []float64{0, 0.5, 0.5, 1, -1, -1}},
} {
t.Run(testCase.name, func(t *testing.T) {
if got := parseCurrentUpgradeCostWeightByUpgradeLevel(testCase.values); got != nil {
t.Fatalf("folded %v to %v, want nil", testCase.values, got)
}
})
}
}
func TestCostWeightForCurrentLevel(t *testing.T) {
absent := &currentUpgradeTable{}
if got := absent.costWeightForCurrentLevel(10); got != 1 {
t.Fatalf("section-less table coefficient=%v want 1", got)
}
table := &currentUpgradeTable{CostWeightByUpgradeLevel: []float64{0.5, 1.0, 2.5}}
for _, testCase := range []struct {
level int
want float64
}{
{level: -1, want: 0.5},
{level: 0, want: 0.5},
{level: 1, want: 1.0},
{level: 2, want: 2.5},
{level: 3, want: 2.5},
{level: 40, want: 2.5},
} {
if got := table.costWeightForCurrentLevel(testCase.level); got != testCase.want {
t.Fatalf("current level %d coefficient=%v want %v", testCase.level, got, testCase.want)
}
}
}
func TestCurrentUpgradePenaltyForAttemptUsesSharedReinforcementAndAmplificationBoundary(t *testing.T) {
table := &currentUpgradeTable{DestroyLevelByRarity: map[string]int{"common": 7}}
for _, tc := range []struct {
name string
mode string
currentLevel int
row currentUpgradeTableRow
wantType int
wantValue int
}{
{name: "below downgrade boundary keeps PVF", mode: "reinforce", currentLevel: 6, row: currentUpgradeTableRow{PenaltyType: 0, PenaltyValue: 4}, wantType: 0, wantValue: 4},
{name: "plus seven failure drops to six", mode: "reinforce", currentLevel: 7, row: currentUpgradeTableRow{PenaltyType: 3}, wantType: 1},
{name: "plus eight failure drops to seven", mode: "reinforce", currentLevel: 8, row: currentUpgradeTableRow{PenaltyType: 3}, wantType: 1},
{name: "plus nine failure drops to eight", mode: "reinforce", currentLevel: 9, row: currentUpgradeTableRow{PenaltyType: 3}, wantType: 1},
{name: "plus ten failure destroys", mode: "reinforce", currentLevel: 10, row: currentUpgradeTableRow{PenaltyType: 2, PenaltyValue: 3}, wantType: 3},
{name: "above plus ten failure destroys", mode: "reinforce", currentLevel: 11, row: currentUpgradeTableRow{PenaltyType: 2, PenaltyValue: 3}, wantType: 3},
{name: "plus seven amplification failure drops to six", mode: "amplify", currentLevel: 7, row: currentUpgradeTableRow{PenaltyType: 2, PenaltyValue: 7}, wantType: 1},
{name: "plus eight amplification failure drops to seven", mode: "amplify", currentLevel: 8, row: currentUpgradeTableRow{PenaltyType: 2, PenaltyValue: 8}, wantType: 1},
{name: "plus nine amplification failure drops to eight", mode: "amplify", currentLevel: 9, row: currentUpgradeTableRow{PenaltyType: 2, PenaltyValue: 9}, wantType: 1},
{name: "plus ten amplification failure destroys", mode: "amplify", currentLevel: 10, row: currentUpgradeTableRow{PenaltyType: 3}, wantType: 3},
} {
t.Run(tc.name, func(t *testing.T) {
gotType, gotValue := currentUpgradePenaltyForAttempt(tc.mode, tc.currentLevel, tc.row, table)
if gotType != tc.wantType || gotValue != tc.wantValue {
t.Fatalf("penalty=(%d,%d), want (%d,%d)", gotType, gotValue, tc.wantType, tc.wantValue)
}
})
}
}