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

149 lines
6.8 KiB
Go

package dnfbridge
import (
"os"
"sort"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// op30 previously had no inbound route at all: the gameplay module registry did
// not own it and it was not an aligned command, so the legacy dispatcher's
// default branch logged "dnfbridge unhandled latest game command cmd=1 type=30"
// and dropped the request. Both transports must now reach the inventory domain.
func TestIncreaseStatusIsRoutedToTheInventoryDomain(t *testing.T) {
opcode := uint16(dnfenum.CmdPacketIncreaseStatus)
if _, owned := currentGameplayModules.legacyHandlers[opcode]; owned {
t.Fatalf("op30 must stay on the aligned inventory route, not a gameplay module")
}
if _, owned := currentGameplayModules.upperHandlers[opcode]; owned {
t.Fatalf("op30 must stay on the aligned inventory route, not a gameplay module")
}
decision, classified := alignedcmd.Classify(opcode)
if !classified || decision.Domain != dnfenum.AlignedDomainInventory ||
decision.Support != dnfenum.AlignedSupportDirect || decision.Action != alignedcmd.ActionPendingModule {
t.Fatalf("classified=%t decision=%+v", classified, decision)
}
}
// op30 carries no item identity, so its owner depends on exactly the same three
// PVF-backed inputs op44 uses. Each gate is opcode-keyed and must admit op30.
func TestIncreaseStatusOpensEveryPVFBackedOwnerInput(t *testing.T) {
if !alignedSkillOwnerRulesRequired(dnfenum.CmdPacketIncreaseStatus) {
t.Fatal("op30 SP/TP books need the active-PVF skill catalog and point baseline")
}
service := &Service{}
if _, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketIncreaseStatus); err == nil {
t.Fatal("op30 must ask for the stackable-use resolver, so a missing catalog has to be reported")
}
if _, err := service.alignedDirectExperienceConsumablePlannerForCommand(dnfenum.CmdPacketIncreaseStatus); err == nil {
t.Fatal("op30 must ask for the direct-experience planner, so a missing catalog has to be reported")
}
// An unrelated opcode still receives no resolver and no error.
resolver, err := service.alignedStackableUseResolverForCommand(dnfenum.CmdPacketSortItem)
if resolver != nil || err != nil {
t.Fatalf("unrelated opcode resolver=%v err=%v", resolver != nil, err)
}
}
func TestIncreaseStatusRealPVFResolvesEveryBookTheClientSendsItFor(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the runtime op30 book families")
}
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 map[int64]struct {
path string
kind alignedcmd.SkillPointBookKind
delta int64
}{
1031: {path: "stackable/book_skill1.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 5},
1038: {path: "stackable/book_skill2.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 20},
1204: {path: "stackable/book_fskill1.stk", kind: alignedcmd.SkillPointBookAddTP, delta: 1},
1205: {path: "stackable/book_fskill2.stk", kind: alignedcmd.SkillPointBookAddTP, delta: 5},
80003: {path: "stackable/extention/test_book_skill1.stk", kind: alignedcmd.SkillPointBookAddSP, delta: 5},
1206: {path: "stackable/book_fskillreset.stk", kind: alignedcmd.SkillPointBookResetTP},
1253: {path: "stackable/book_fskillreset_event.stk", kind: alignedcmd.SkillPointBookResetTP},
} {
resolution, resolveErr := resolver(itemID)
if resolveErr != nil || resolution.PVFPath != want.path || resolution.TPBook == nil ||
resolution.TPBook.Kind != want.kind || resolution.TPBook.Delta != want.delta ||
resolution.TPBook.MinimumLevel <= 0 {
t.Fatalf("runtime SP/TP book %d resolution=%+v err=%v", itemID, resolution, resolveErr)
}
}
// Every runtime [increase status type] item is [etc], the same stackable
// family the client sends op30 for. They must reach op30 through exactly the
// planner op44 already owns, with their amounts read from the PVF operands.
capsules := 0
// The six numbered character-experience books, on the other hand, declare no
// effect at all in the active PVF: they are bare [etc] stackables whose only
// numeric trace is the localized name. They reach the same planner through
// the path-keyed fallback in currentPVFCharacterExperienceBook, audited
// against that name, so no stackable/book_exp* path may stay unowned here.
undeclaredCharacterBooks := make([]string, 0, 6)
ownedCharacterBooks := make([]string, 0, 6)
for _, itemID := range catalog.stackableIDs {
definition, resolveErr := catalog.ResolveItem(itemID)
if resolveErr != nil {
t.Fatalf("resolve stackable item=%d: %v", itemID, resolveErr)
}
resolution, resolveErr := resolver(int64(itemID))
if resolveErr != nil {
continue
}
if normalizeDirectExperienceType(definition.DirectExperienceType) != "" {
if normalizeDungeonDropStackableType(definition.StackableType) != "[etc]" {
t.Fatalf("increase-status item %d path=%s is %q, not [etc]", itemID, definition.PVFPath, definition.StackableType)
}
if resolution.DirectExperience == nil ||
normalizeDirectExperienceType(resolution.DirectExperience.EffectType) == "" ||
len(resolution.DirectExperience.EffectValues) == 0 {
t.Fatalf("increase-status item %d path=%s resolution=%+v", itemID, definition.PVFPath, resolution)
}
capsules++
}
normalized := strings.ToLower(strings.ReplaceAll(definition.PVFPath, "\\", "/"))
if !strings.HasPrefix(normalized, "stackable/book_exp") || strings.Contains(normalized, "guild") {
continue
}
if resolution.DirectExperience == nil || resolution.TPBook != nil ||
normalizeDirectExperienceType(resolution.DirectExperience.EffectType) != "[expup]" ||
len(resolution.DirectExperience.EffectValues) != 1 ||
resolution.DirectExperience.EffectValues[0] <= 0 {
undeclaredCharacterBooks = append(undeclaredCharacterBooks, definition.PVFPath)
continue
}
ownedCharacterBooks = append(ownedCharacterBooks, definition.PVFPath)
}
if capsules < 20 {
t.Fatalf("runtime [increase status type] items reachable through op30: %d", capsules)
}
sort.Strings(undeclaredCharacterBooks)
if len(undeclaredCharacterBooks) != 0 {
t.Fatalf("op30 character-experience books still without a grant (%d): %v",
len(undeclaredCharacterBooks), undeclaredCharacterBooks)
}
sort.Strings(ownedCharacterBooks)
if len(ownedCharacterBooks) != len(currentCharacterExperienceBookAmounts) {
t.Fatalf("op30 owns %d character-experience books, want %d: %v",
len(ownedCharacterBooks), len(currentCharacterExperienceBookAmounts), ownedCharacterBooks)
}
}