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

206 lines
8.9 KiB
Go

package dnfbridge
import (
"fmt"
"os"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// currentCharacterExperienceBookAmounts is the expectation the table in
// stackable_use_pvf.go has to keep reproducing, spelled out separately so a
// silent edit of one of the two cannot pass unnoticed.
var currentCharacterExperienceBookAmounts = map[int64]struct {
path string
amount int64
}{
1034: {path: "stackable/book_exp1.stk", amount: 100},
1035: {path: "stackable/book_exp2.stk", amount: 1000},
1036: {path: "stackable/book_exp3.stk", amount: 10000},
1037: {path: "stackable/book_exp4.stk", amount: 100000},
1231: {path: "stackable/book_exp5.stk", amount: 1000000},
1232: {path: "stackable/book_exp6.stk", amount: 10000000},
}
// currentCharacterExperienceBookRefusedItems are the two families that spend a
// different experience currency. They must keep resolving to nothing so op30
// answers them with its failure ACK.
var currentCharacterExperienceBookRefusedItems = []int64{1121, 1122, 1123, 1200, 1201, 1202}
func newCharacterExperienceBookTestSource() dungeonDropCatalogTestSource {
return dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "1034 `book_exp1.stk`\n1035 `book_exp2.stk`\n1036 `book_exp3.stk`\n1037 `book_exp4.stk`\n1231 `book_exp5.stk`\n1232 `book_exp6.stk`\n1121 `book_duelexp1.stk`\n1200 `book_guildexp1.stk`\n",
"stackable/book_exp1.stk": "[name] `增加100经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_exp2.stk": "[name] `增加1000经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_exp3.stk": "[name] `增加10000经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_exp4.stk": "[name] `增加100000经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_exp5.stk": "[name] `增加1000000经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_exp6.stk": "[name] `增加10000000经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
// The two refused families are shaped exactly like the six above, so
// only the table separates them.
"stackable/book_duelexp1.stk": "[name] `增加100决斗场经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
"stackable/book_guildexp1.stk": "[name] `增加100公会经验值的书`\n[minimum level] 1\n[stackable type] `[etc]` 1\n",
}
}
func TestAlignedStackableUseResolverGrantsNumberedCharacterExperienceBooks(t *testing.T) {
catalog, err := newPVFDungeonDropCatalog(newCharacterExperienceBookTestSource())
if err != nil {
t.Fatal(err)
}
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketIncreaseStatus)
if err != nil {
t.Fatal(err)
}
for itemID, want := range currentCharacterExperienceBookAmounts {
resolution, resolveErr := resolver(itemID)
if resolveErr != nil || resolution.PVFPath != want.path || resolution.StackableType != "[etc]" ||
resolution.DirectExperience == nil || resolution.TPBook != nil {
t.Fatalf("experience book %d resolution=%+v err=%v", itemID, resolution, resolveErr)
}
effect := *resolution.DirectExperience
if effect.ItemID != itemID || effect.PVFPath != want.path || effect.EffectType != "[expup]" ||
len(effect.EffectValues) != 1 || effect.EffectValues[0] != want.amount {
t.Fatalf("experience book %d effect=%+v want amount %d", itemID, effect, want.amount)
}
}
for _, itemID := range []int64{1121, 1200} {
resolution, resolveErr := resolver(itemID)
if resolveErr != nil || resolution.DirectExperience != nil || resolution.TPBook != nil {
t.Fatalf("duel/guild experience book %d must stay unowned: resolution=%+v err=%v", itemID, resolution, resolveErr)
}
}
}
func TestCurrentPVFCharacterExperienceBookTableMatchesDeclaredAmounts(t *testing.T) {
for _, want := range currentCharacterExperienceBookAmounts {
amount, found := currentPVFCharacterExperienceBook(want.path)
if !found || amount != want.amount {
t.Fatalf("table row %s amount=%d found=%t, want %d", want.path, amount, found, want.amount)
}
}
for _, path := range []string{
"stackable/book_duelexp1.stk",
"stackable/book_duelexp2.stk",
"stackable/book_duelexp3.stk",
"stackable/book_guildexp1.stk",
"stackable/book_guildexp2.stk",
"stackable/book_guildexp3.stk",
"stackable/book_fskillreset.stk",
} {
if amount, found := currentPVFCharacterExperienceBook(path); found {
t.Fatalf("path %s must stay out of the table, got amount %d", path, amount)
}
}
}
func TestAuditCurrentPVFCharacterExperienceBookRejectsDriftedContracts(t *testing.T) {
valid := dungeonDropItemDefinition{
ItemID: 1034,
Kind: dungeonDropItemStackable,
PVFPath: "stackable/book_exp1.stk",
Name: "增加100经验值的书",
StackableType: "`[etc]`",
}
if err := auditCurrentPVFCharacterExperienceBook(valid, 100); err != nil {
t.Fatalf("valid contract rejected: %v", err)
}
// A wrong declared amount stops matching the name the PVF itself carries.
if err := auditCurrentPVFCharacterExperienceBook(valid, 1000); err == nil {
t.Fatal("declared amount 1000 must not pass against the name 增加100经验值的书")
}
// The reverse direction: the shorter prefix must not slide into the longer
// name either, or every book would validate against the smallest amount.
thousand := valid
thousand.Name = "增加1000经验值的书"
if err := auditCurrentPVFCharacterExperienceBook(thousand, 100); err == nil {
t.Fatal("declared amount 100 must not pass against the name 增加1000经验值的书")
}
for name, drifted := range map[string]dungeonDropItemDefinition{
"waste stackable": func() dungeonDropItemDefinition {
d := valid
d.StackableType = "`[waste]`"
return d
}(),
"declared action": func() dungeonDropItemDefinition {
d := valid
d.ActionType = "`[level up ticket]`"
return d
}(),
"declared increase status type": func() dungeonDropItemDefinition {
d := valid
d.DirectExperienceType = "`[expUp]`"
return d
}(),
"renamed entry": func() dungeonDropItemDefinition {
d := valid
d.Name = "增加100决斗场经验值的书"
return d
}(),
} {
if err := auditCurrentPVFCharacterExperienceBook(drifted, 100); err == nil {
t.Fatalf("%s must fail the experience-book audit", name)
}
}
}
func TestAlignedStackableUseResolverRealPVFAuditsEveryCharacterExperienceBook(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to audit the runtime character experience books")
}
source, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
resolver, err := (&Service{pvfItemCatalog: catalog}).alignedStackableUseResolverForCommand(dnfenum.CmdPacketIncreaseStatus)
if err != nil {
t.Fatal(err)
}
for itemID, want := range currentCharacterExperienceBookAmounts {
definition, defErr := catalog.ResolveItem(uint32(itemID))
if defErr != nil {
t.Fatalf("resolve runtime experience book %d: %v", itemID, defErr)
}
if definition.PVFPath != want.path {
t.Fatalf("runtime experience book %d path=%s, want %s", itemID, definition.PVFPath, want.path)
}
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" {
t.Fatalf("runtime experience book %d path=%s type=%q, want [etc]", itemID, definition.PVFPath, definition.StackableType)
}
gotName := fmt.Sprintf("%q", definition.Name)
if auditErr := auditCurrentPVFCharacterExperienceBook(definition, want.amount); auditErr != nil {
t.Fatalf("runtime experience book %d path=%s name=%s: %v", itemID, definition.PVFPath, gotName, auditErr)
}
resolution, resolveErr := resolver(itemID)
if resolveErr != nil || resolution.DirectExperience == nil || resolution.TPBook != nil ||
resolution.DirectExperience.EffectType != "[expup]" ||
len(resolution.DirectExperience.EffectValues) != 1 ||
resolution.DirectExperience.EffectValues[0] != want.amount {
t.Fatalf("runtime experience book %d name=%s resolution=%+v err=%v", itemID, gotName, resolution, resolveErr)
}
}
for _, itemID := range currentCharacterExperienceBookRefusedItems {
definition, defErr := catalog.ResolveItem(uint32(itemID))
if defErr != nil {
t.Fatalf("resolve runtime refused experience book %d: %v", itemID, defErr)
}
if _, found := currentPVFCharacterExperienceBook(definition.PVFPath); found {
t.Fatalf("runtime item %d path=%s must stay out of the experience-book table", itemID, definition.PVFPath)
}
resolution, resolveErr := resolver(itemID)
if resolveErr != nil || resolution.DirectExperience != nil || resolution.TPBook != nil {
t.Fatalf("runtime duel/guild experience book %d path=%s must stay unowned: resolution=%+v err=%v",
itemID, definition.PVFPath, resolution, resolveErr)
}
}
}