DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,257 @@
package dnfbridge
import (
"context"
"reflect"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
"longheng.io/server/internal/modules/dnf/skillcmd"
)
func TestProtectedProfessionSkillLevelsKeepFreeAwakeningAndExcludePurchasedPassives(t *testing.T) {
profiles, catalog := cumulativeProfessionSkillResources(t)
tests := []struct {
name string
growType byte
want map[uint16]int
}{
{name: "base", growType: 0x00, want: map[uint16]int{10: 7}},
{name: "class change", growType: 0x03, want: map[uint16]int{10: 7, 20: 1}},
{name: "first awakening", growType: 0x13, want: map[uint16]int{10: 7, 20: 1, 83: 1}},
{name: "second awakening", growType: 0x23, want: map[uint16]int{10: 7, 20: 1, 83: 1}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
levels, ok := protectedProfessionSkillLevels(profiles, catalog, 3, test.growType, 90)
if !ok || !reflect.DeepEqual(levels, test.want) {
t.Fatalf("protected levels=%v found=%t want=%v", levels, ok, test.want)
}
})
}
}
func TestProtectedProfessionSkillLevelsExcludeClearFlapperOptionalToggle(t *testing.T) {
profiles, catalog := cumulativeProfessionSkillResources(t)
levels, ok := protectedProfessionSkillLevels(profiles, catalog, 3, 0x04, 90)
if !ok {
t.Fatal("witch protected profession levels unavailable")
}
if _, protected := levels[167]; protected {
t.Fatalf("optional clear-flapper toggle entered permanent skill floor: %v", levels)
}
definition, found := catalog.Find(3, 167)
if !found || !strings.EqualFold(definition.Path, "skill/mage/clearflapper.skl") || len(definition.Prerequisites) != 1 ||
definition.Prerequisites[0].SkillID != 95 || definition.Prerequisites[0].Level != 1 || definition.LevelCost(0) != 0 {
t.Fatalf("clear-flapper evidence definition=%+v found=%t", definition, found)
}
}
func TestSkillResetRestoresCumulativeProfessionFloorInBothTrees(t *testing.T) {
ctx := context.Background()
profiles, catalog := cumulativeProfessionSkillResources(t)
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:test",
Job: "3",
Level: 90,
Stats: map[string]int64{"grow_type": 0x23},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
points := dnfrepo.SkillPointState{TotalSP: 1000, RemainingSP: 100, TotalTP: 20, RemainingTP: 2, SyncedLevel: 90}
record := dnfrepo.SkillRecord{
CharacterID: character.CharacterID,
Skills: map[int64]dnfrepo.SkillState{
10: {Level: 9, Enabled: true},
83: {Level: 12, Enabled: true},
84: {Level: 16, Enabled: true},
99: {Level: 5, Enabled: true},
126: {Level: 6, Enabled: true},
},
Points: points,
Layouts: map[int]dnfrepo.SkillLayout{
0: {0: 99},
1: {0: 99},
},
}
dnfrepo.SetSkillTree(&record, 1, dnfrepo.SkillTreeState{
Skills: map[int64]dnfrepo.SkillState{
10: {Level: 9, Enabled: true},
83: {Level: 12, Enabled: true},
84: {Level: 16, Enabled: true},
99: {Level: 5, Enabled: true},
126: {Level: 6, Enabled: true},
},
Points: points,
})
if err := repositories.Skill.Save(ctx, record); err != nil {
t.Fatal(err)
}
service := &Service{
professionProfiles: profiles,
skillCatalog: catalog,
initialSPTable: map[int]int{90: 1000},
initialTPTable: map[int]int{90: 20},
}
resolvedCatalog, levels, baseline := service.skillOwnerRules(ctx, repositories, 77)
want := map[uint16]int{10: 7, 20: 1, 83: 1}
if resolvedCatalog != catalog || !reflect.DeepEqual(levels, want) || baseline == nil || baseline.RemainingSP != 1000 || baseline.RemainingTP != 20 {
t.Fatalf("reset rules catalog=%p levels=%v baseline=%+v", resolvedCatalog, levels, baseline)
}
owner, err := skillcmd.NewOwner(repositories, skillcmd.OwnerOptions{
Catalog: resolvedCatalog,
InitialLevels: levels,
PointBaseline: baseline,
})
if err != nil {
t.Fatal(err)
}
for tree := byte(0); tree < currentSkillInfoTreeCount; tree++ {
if _, err := owner.ApplyReset(ctx, skillcmd.NewResetCommand(
alignedcmd.Request{AccountID: "dnf:test", SelectedCharacterID: 77},
skillcmd.SkillInitRequest{SkillTree: tree, Mode: 0},
)); err != nil {
t.Fatalf("reset tree %d: %v", tree, err)
}
}
saved, found, err := repositories.Skill.Load(ctx, character.CharacterID)
if err != nil || !found {
t.Fatalf("load reset record found=%t err=%v", found, err)
}
for tree := 0; tree < currentSkillInfoTreeCount; tree++ {
state, ok := dnfrepo.SkillTree(saved, tree)
if !ok || len(state.Skills) != len(want) {
t.Fatalf("tree %d state=%+v found=%t", tree, state, ok)
}
for skillID, level := range want {
if got := state.Skills[int64(skillID)]; got.Level != level || !got.Enabled {
t.Fatalf("tree %d skill %d=%+v want level=%d enabled", tree, skillID, got, level)
}
}
for _, purchasedSkillID := range []int64{84, 99, 126} {
if _, stale := state.Skills[purchasedSkillID]; stale {
t.Fatalf("tree %d retained purchased skill %d: %+v", tree, purchasedSkillID, state.Skills)
}
}
}
}
// TestSkillOwnerRulesBaselineIsTheOrdinaryPVFPointTarget pins the contract that
// makes +SP/+TP books work at all, and guards the regression that folding the
// book bonus into this baseline caused.
//
// inventory.useTPBook receives this value as alignedcmd.Request.SkillPointBaseline
// and adds the persisted bonus_sp/bonus_tp itself (skillPointBookTarget does
// `baseline.TotalSP += bonusSP`), so a baseline that ALREADY carried the bonus
// would count the accumulated bonus twice on every book use and hand out
// quadratic free SP (N books of +d grant d*N*(N-1)/2 extra).
//
// The skill plan mutations that do need the bonus - op29 buy_skill and op491
// skill reset - add it themselves in skillcmd.skillPointTargetForCharacter.
func TestSkillOwnerRulesBaselineIsTheOrdinaryPVFPointTarget(t *testing.T) {
ctx := context.Background()
profiles, catalog := cumulativeProfessionSkillResources(t)
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:test",
Job: "3",
Level: 90,
Stats: map[string]int64{"grow_type": 0x23, "bonus_sp": 180, "bonus_tp": 4},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
service := &Service{
professionProfiles: profiles,
skillCatalog: catalog,
initialSPTable: map[int]int{90: 1000},
initialTPTable: map[int]int{90: 20},
}
resolvedCatalog, _, baseline := service.skillOwnerRules(ctx, repositories, 77)
if resolvedCatalog != catalog || baseline == nil {
t.Fatalf("skill owner rules catalog=%p baseline=%+v", resolvedCatalog, baseline)
}
if baseline.TotalSP != 1000 || baseline.RemainingSP != 1000 ||
baseline.TotalTP != 20 || baseline.RemainingTP != 20 || baseline.SyncedLevel != 90 {
t.Fatalf("baseline = %+v, want the ordinary PVF SP/TP tables 1000/20 with NO book bonus; "+
"a bonus-inclusive baseline double-counts bonus_sp=180/bonus_tp=4 on every book use", baseline)
}
// The bonus belongs to the login/GM/book target instead. Assert the two
// differ by exactly the persisted bonus so the split stays visible.
target, err := service.currentPVFSkillPointTarget(ctx, character)
if err != nil {
t.Fatal(err)
}
if target.TotalSP != 1180 || target.TotalTP != 24 {
t.Fatalf("shared PVF+bonus target = %+v, want 1180/24", target)
}
if target.TotalSP-baseline.TotalSP != int(character.Stats["bonus_sp"]) ||
target.TotalTP-baseline.TotalTP != int(character.Stats["bonus_tp"]) {
t.Fatalf("baseline %+v and target %+v must differ by exactly bonus_sp=%d/bonus_tp=%d",
*baseline, target, character.Stats["bonus_sp"], character.Stats["bonus_tp"])
}
}
func cumulativeProfessionSkillResources(t *testing.T) (*dnfprofession.Profiles, *dnfskill.Table) {
t.Helper()
source := finishBridgePVFSource{
dnfprofession.DefaultCharacterList: "3 `Mage/Mage.chr`\n",
"character/Mage/Mage.chr": "[initial value]\n[skill]\n10 7\n[/skill]\n" +
"[growtype 4]\n[skill]\n20 1\n[/skill]\n" +
"[awakening 1]\n[awakening skill]\n83 1\n84 1\n[/awakening skill]\n" +
"[awakening 2]\n[awakening skill]\n[/awakening skill]\n",
dnfskill.DefaultList: "3 `MageSkill.lst`\n",
"skill/MageSkill.lst": "10 `Mage/base.skl` 20 `Mage/class.skl` 83 `Mage/bellatrix.skl` 84 `Mage/combomastery.skl` 95 `Mage/flapper.skl` 99 `Mage/purchased.skl` 126 `Mage/multirole.skl` 167 `Mage/clearflapper.skl`\n",
"skill/Mage/base.skl": "[name]\n`base`\n[type]\n`[passive]`\n",
"skill/Mage/class.skl": "[name]\n`class`\n[type]\n`[active]`\n",
"skill/Mage/bellatrix.skl": "[name]\n`变身贝亚娜`\n[type]\n`[active]`\n[purchase cost]\n0\n[/purchase cost]\n",
"skill/Mage/combomastery.skl": "[name]\n`斗神意志`\n[type]\n`[passive]`\n[purchase cost]\n45\n[/purchase cost]\n",
"skill/Mage/flapper.skl": "[name]\n`flapper`\n[type]\n`[active]`\n",
"skill/Mage/purchased.skl": "[name]\n`purchased`\n[type]\n`[active]`\n",
"skill/Mage/multirole.skl": "[name]\n`战灵潜能`\n[type]\n`[passive]`\n[purchase cost]\n80\n[/purchase cost]\n",
"skill/Mage/clearflapper.skl": `[name]
` + "`苍蝇拍 : 魔法禁锢`" + `
[purchase cost]
0
[/purchase cost]
[pre required skill]
95 1
[/pre required skill]
[required level]
30
[skill class]
3
[maximum level]
1
[growtype maximum level]
0 0 0 0 1 0
[/growtype maximum level]
[skill fitness growtype]
4
[/skill fitness growtype]
`,
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
profiles, err := dnfprofession.LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
return profiles, catalog
}