服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
258 lines
10 KiB
Go
258 lines
10 KiB
Go
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
|
|
}
|