Files
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

410 lines
15 KiB
Go

package dnfbridge
import (
"context"
"os"
"path/filepath"
"testing"
"time"
darkcombo "longheng.io/server/internal/modules/dnf/darkknightcombo"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestBuildCharacterPVFInitializationFollowsCSharpSources(t *testing.T) {
source := initialEquipmentMemSource{
"character/character.lst": "15 `fighter.chr`\n",
"character/fighter.chr": `
[job]
` + "`[fighter]`" + `
[initial value]
[HP MAX] 100
[MP MAX] 50
[strength] 8
[physical attack] 3
[inventory limit] 48000
[skill]
1001 1
1002 2
[/skill]
[create equipment list]
[weapon] 101
[coat] 102
[/create equipment list]
[growtype 1]
[HP MAX] 10
`,
"equipment/equipment.lst": "101 `weapon/spear.equ`\n102 `armor/coat.equ`\n",
"equipment/weapon/spear.equ": "[durability]\n33\n[equipment type]\n1\n",
"equipment/armor/coat.equ": "[durability]\n40\n[equipment type]\n2\n",
"skill/skilllist.lst": "15 `fighter_skill.lst`\n",
"skill/fighter_skill.lst": "1001 `fighter/active.skl`\n1002 `fighter/passive.skl`\n",
"skill/fighter/active.skl": "[skill type]\n`active`\n",
"skill/fighter/passive.skl": "[skill type]\n`passive`\n",
"Etc/spTable.etc": "[sp table]\n1 20\n2 30\n[/sp table]\n[tp table]\n50 1\n[/tp table]\n",
}
init, err := buildCharacterPVFInitializationFromSource(context.Background(), source, 15, 2, 0)
if err != nil {
t.Fatalf("build initialization: %v", err)
}
if !init.HasStats || init.Stats.HPMax != 1100 || init.Stats.MPMax != 500 || init.Stats.InventoryLimit != 480000 {
t.Fatalf("stats = %+v has=%t", init.Stats, init.HasStats)
}
if len(init.Equipment) != 2 || init.Equipment[0].Slot != 11 || init.Equipment[0].ItemID != 101 || init.Equipment[0].Durability != 33 {
t.Fatalf("equipment = %+v", init.Equipment)
}
if len(init.Skills) != 2 {
t.Fatalf("skills = %+v", init.Skills)
}
if init.Skills[0].SkillID != 1001 || init.Skills[0].Level != 1 {
t.Fatalf("active skill = %+v", init.Skills[0])
}
if init.Skills[1].SkillID != 1002 || init.Skills[1].Level != 2 {
t.Fatalf("passive skill = %+v", init.Skills[1])
}
if init.SkillPoints.TotalSP != 50 || init.SkillPoints.RemainingSP != 50 || init.SkillPoints.TotalTP != 0 || init.SkillPoints.SyncedLevel != 2 {
t.Fatalf("skill points = %+v", init.SkillPoints)
}
}
func TestBuildNewDarkKnightComboSettingsSeedsTwoIndependentPVFPages(t *testing.T) {
source := initialEquipmentMemSource{
"skill/skilllist.lst": "9 `demonicswordman/skilllist.lst`\n",
"skill/demonicswordman/skilllist.lst": "1 `attack.skl`\n100 `comboset1.skl`\n",
"skill/demonicswordman/attack.skl": "[name] `attack`\n[type] `active`\n",
"skill/demonicswordman/comboset1.skl": "[name] `combo 1`\n[combo set]\n1\n[/combo set]\n",
}
catalog, err := buildSkillCatalogFromSource(context.Background(), source)
if err != nil {
t.Fatal(err)
}
primary := dnfrepo.SkillTreeState{Skills: map[int64]dnfrepo.SkillState{
1: {Level: 1, Enabled: true},
100: {Level: 1, Enabled: true},
}}
skill := dnfrepo.SkillRecord{CharacterID: "77"}
dnfrepo.SetSkillTree(&skill, 0, primary)
dnfrepo.SetSkillTree(&skill, 1, primary)
record, err := buildNewDarkKnightComboSettingsFromPVF(source, catalog, "77", skill, time.Unix(10, 0))
if err != nil {
t.Fatal(err)
}
repositories := testRepositoryGroup()
if err := repositories.Settings.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
pages, found, err := darkcombo.LoadPages(context.Background(), repositories.Settings, "77")
if err != nil || !found {
t.Fatalf("load combo pages found=%t err=%v", found, err)
}
if len(pages) != darkcombo.MaximumPageCount || pages[0].Index != 0 || pages[1].Index != 1 {
t.Fatalf("combo pages=%+v", pages)
}
for _, page := range pages {
if len(page.Roots) != 1 || page.Roots[0].SkillID != 100 || len(page.Roots[0].Children) != 1 || page.Roots[0].Children[0] != 1 {
t.Fatalf("combo page=%+v", page)
}
}
}
func TestRealScriptPVFBuildsTwoNewDarkKnightComboPages(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to run real Script.pvf Dark Knight creation smoke")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
init, err := buildCharacterPVFInitializationFromSource(context.Background(), archive, darkcombo.Job, 1, 0)
if err != nil {
t.Fatal(err)
}
character := dnfrepo.CharacterRecord{CharacterID: "77", Job: "9", Level: 1}
skill := initialSkillRecord(character, init, time.Unix(10, 0))
primary, found := dnfrepo.SkillTree(skill, 0)
if !found {
t.Fatal("real PVF Dark Knight primary tree is empty")
}
dnfrepo.SetSkillTree(&skill, 1, primary)
catalog, err := buildSkillCatalogFromSource(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
record, err := buildNewDarkKnightComboSettingsFromPVF(archive, catalog, "77", skill, time.Unix(10, 0))
if err != nil {
t.Fatal(err)
}
repositories := testRepositoryGroup()
if err := repositories.Settings.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
pages, found, err := darkcombo.LoadPages(context.Background(), repositories.Settings, "77")
if err != nil || !found || len(pages) != darkcombo.MaximumPageCount {
t.Fatalf("real combo pages=%+v found=%t err=%v", pages, found, err)
}
for _, page := range pages {
if len(page.Roots) == 0 {
t.Fatalf("real combo page %d has no roots", page.Index)
}
}
}
func TestParseInitialSkillPointsUsesPVFTPTable(t *testing.T) {
points, err := parseInitialSkillPointsFromSource(initialEquipmentMemSource{
"Etc/spTable.etc": "[sp table]\n1 0\n2 30\n50 60\n[/sp table]\n[tp table]\n50 1\n51 2\n[/tp table]\n",
}, 51)
if err != nil {
t.Fatal(err)
}
if points.TotalSP != 90 || points.RemainingSP != 90 || points.TotalTP != 3 || points.RemainingTP != 3 || points.SyncedLevel != 51 {
t.Fatalf("skill points=%+v", points)
}
}
func TestSaveNewCharacterPVFFailureLeavesNoPartialRecords(t *testing.T) {
repos := testRepositoryGroup()
service := &Service{options: options{pvfPath: filepath.Join(t.TempDir(), "missing.pvf")}}
now := time.Now().UTC()
record := dnfrepo.CharacterRecord{
CharacterID: "199",
AccountID: "dnf:1",
Slot: 0,
Name: "hero",
Job: "15",
Level: 1,
Stats: defaultCreatedCharacterStats(0),
CreatedAt: now,
UpdatedAt: now,
}
if err := service.saveNewCharacter(context.Background(), repos, record); err == nil {
t.Fatal("save new character succeeded without PVF initialization")
}
if _, found, err := repos.Character.Load(context.Background(), "199"); err != nil || found {
t.Fatalf("partial character found=%t err=%v", found, err)
}
if _, found, err := repos.Inventory.Load(context.Background(), "199"); err != nil || found {
t.Fatalf("orphan inventory found=%t err=%v", found, err)
}
if _, found, err := repos.Equipment.Load(context.Background(), "199"); err != nil || found {
t.Fatalf("orphan equipment found=%t err=%v", found, err)
}
if _, found, err := repos.Skill.Load(context.Background(), "199"); err != nil || found {
t.Fatalf("orphan skills found=%t err=%v", found, err)
}
if _, found, err := repos.Settings.Load(context.Background(), newCharacterContainerStateSettingsScope("199")); err != nil || found {
t.Fatalf("orphan settings found=%t err=%v", found, err)
}
}
func TestSaveNewCharacterSeedsPVFInitializationSnapshot(t *testing.T) {
repos := testRepositoryGroup()
now := time.Now().UTC()
service := &Service{
initialEquipmentByJob: map[byte][]initialEquipmentEntry{
15: {
{
Slot: 11,
ItemID: 900001,
Durability: 27,
EquipType: 1,
PVFPath: "equipment/weapon/test.equ",
RawEntry: buildInitialEquipmentRawEntry(11, 900001, 27),
},
},
},
initialSkillsByJob: map[byte][]initialSkillEntry{
15: {
{SkillID: 1001, Level: 1},
{SkillID: 1002, Level: 2},
},
},
initialSPTable: map[int]int{1: 20},
}
record := dnfrepo.CharacterRecord{
CharacterID: "177",
AccountID: "dnf:1",
Slot: 0,
Name: "hero",
Job: "15",
Level: 1,
Stats: defaultCreatedCharacterStats(0),
CreatedAt: now,
UpdatedAt: now,
}
if err := service.saveNewCharacter(context.Background(), repos, record); err != nil {
t.Fatalf("save new character: %v", err)
}
equipment, found, err := repos.Equipment.Load(context.Background(), "177")
if err != nil || !found || len(equipment.Entries) != 1 {
t.Fatalf("equipment found=%t err=%v entries=%+v", found, err, equipment.Entries)
}
skills, found, err := repos.Skill.Load(context.Background(), "177")
if err != nil || !found {
t.Fatalf("skills found=%t err=%v", found, err)
}
if got := skills.Skills[1001]; got.Level != 1 || !got.Enabled {
t.Fatalf("skill 1001 = %+v", got)
}
if got := skills.Skills[1002]; got.Level != 2 || !got.Enabled {
t.Fatalf("skill 1002 = %+v", got)
}
if skills.Points.TotalSP != 20 || skills.Points.RemainingSP != 20 || skills.Points.TotalTP != 0 || skills.Points.SyncedLevel != 1 {
t.Fatalf("skill points = %+v", skills.Points)
}
}
func TestEnsureCharacterInitializationSnapshotBackfillsLegacyRecordsOnce(t *testing.T) {
repos := testRepositoryGroup()
service := &Service{}
prepareTestCharacterInitialization(service, 15)
record := dnfrepo.CharacterRecord{
CharacterID: "178",
AccountID: "dnf:1",
Job: "15",
Level: 1,
}
result, err := service.ensureCharacterInitializationSnapshot(context.Background(), repos, record)
if err != nil {
t.Fatalf("backfill legacy snapshot: %v", err)
}
if !result.Inventory || !result.Equipment || !result.Skill || len(result.Settings) != 3 {
t.Fatalf("backfill result = %+v", result)
}
inventory, found, err := repos.Inventory.Load(context.Background(), "178")
if err != nil || !found || inventory.Slots[csharpReviveCoinSlotKey()].ItemID != csharpReviveCoinItemID {
t.Fatalf("inventory initialized found=%t err=%v inventory=%+v", found, err, inventory)
}
equipment, found, err := repos.Equipment.Load(context.Background(), "178")
if err != nil || !found || len(equipment.Entries) != 0 {
t.Fatalf("empty equipment record initialized found=%t err=%v equipment=%+v", found, err, equipment)
}
// Deliberately change durable state, then ensure a second time. It must
// behave like 86JP's existing-seed branch and make no replacement write.
inventory.Slots["0:1"] = dnfrepo.ItemStack{ItemID: 999, Count: 3}
if err := repos.Inventory.Save(context.Background(), inventory); err != nil {
t.Fatal(err)
}
if equipment.Entries == nil {
equipment.Entries = make(map[string]dnfrepo.EquipmentEntry)
}
equipment.Entries["11"] = dnfrepo.EquipmentEntry{SlotIndex: 11, ItemID: 888}
if err := repos.Equipment.Save(context.Background(), equipment); err != nil {
t.Fatal(err)
}
result, err = service.ensureCharacterInitializationSnapshot(context.Background(), repos, record)
if err != nil {
t.Fatalf("repeat legacy snapshot ensure: %v", err)
}
if result.Changed() {
t.Fatalf("present legacy state was reseeded: %+v", result)
}
inventory, _, _ = repos.Inventory.Load(context.Background(), "178")
equipment, _, _ = repos.Equipment.Load(context.Background(), "178")
if inventory.Slots["0:1"].ItemID != 999 || equipment.Entries["11"].ItemID != 888 {
t.Fatalf("durable state overwritten inventory=%+v equipment=%+v", inventory, equipment)
}
}
func TestSaveNewCharacterSeedsCSharpDefaultState(t *testing.T) {
repos := testRepositoryGroup()
now := time.Now().UTC()
service := &Service{}
prepareTestCharacterInitialization(service, 15)
record := dnfrepo.CharacterRecord{
CharacterID: "188",
AccountID: "dnf:1",
Slot: 0,
Name: "hero",
Job: "15",
Level: newCharacterInitialLevel,
Stats: defaultCreatedCharacterStatsFromRequest(createCharacterRequest{}),
CreatedAt: now,
UpdatedAt: now,
}
if err := service.saveNewCharacter(context.Background(), repos, record); err != nil {
t.Fatalf("save new character: %v", err)
}
saved, found, err := repos.Character.Load(context.Background(), "188")
if err != nil || !found {
t.Fatalf("character found=%t err=%v", found, err)
}
if saved.Level != newCharacterInitialLevel ||
saved.Stats["stat_level"] != csharpSubtype1ProtocolStatLevel ||
saved.Stats["town_id"] != newCharacterInitialTownID ||
saved.Stats["area_id"] != newCharacterInitialAreaID ||
saved.Stats["pos_x"] != newCharacterInitialPosX ||
saved.Stats["pos_y"] != newCharacterInitialPosY ||
saved.Stats["area_state"] != newCharacterInitialAreaState ||
saved.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] != newCharacterInitialLevel ||
saved.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] != int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion) {
t.Fatalf("saved defaults level=%d stats=%+v", saved.Level, saved.Stats)
}
inventory, found, err := repos.Inventory.Load(context.Background(), "188")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
coin := inventory.Slots[csharpReviveCoinSlotKey()]
if coin.ItemID != csharpReviveCoinItemID || coin.Count != 0 || coin.Extra["amount_or_count"] != "0" {
t.Fatalf("revive coin slot = %+v", coin)
}
container, found, err := repos.Settings.Load(context.Background(), newCharacterContainerStateSettingsScope("188"))
if err != nil || !found {
t.Fatalf("container settings found=%t err=%v", found, err)
}
if container.Values["main_list_param16"] != "0" ||
container.Values["avatar_list_param16"] != "0" ||
container.Values["personal_cargo_list_param16"] != "8" ||
container.Values["revive_coin_wallet_slot"] != "1" {
t.Fatalf("container settings = %+v", container.Values)
}
initBodies, found, err := repos.Settings.Load(context.Background(), newCharacterInitBodiesSettingsScope("188"))
if err != nil || !found {
t.Fatalf("init body settings found=%t err=%v", found, err)
}
if initBodies.Values["noti_0035_occurrence_0_len"] != "13" ||
initBodies.Values["noti_0357_occurrence_0_hex"] != "7b03000000000000" ||
initBodies.Values["noti_03d8_occurrence_0_len"] != "204" ||
initBodies.Values["noti_0077_occurrence_1_hex"] != "00" {
t.Fatalf("init bodies = %+v", initBodies.Values)
}
hotkeys, found, err := repos.Settings.Load(context.Background(), newCharacterHotkeySettingsScope("188"))
if err != nil || !found {
t.Fatalf("hotkey settings found=%t err=%v", found, err)
}
if hotkeys.Values["key_type"] != "0" ||
hotkeys.Values["slot_count"] != "99" ||
len(hotkeys.Values["slots_hex"]) < 16 ||
hotkeys.Values["slots_hex"][:16] != "0200000003000100" {
t.Fatalf("hotkeys = %+v", hotkeys.Values)
}
}
func TestCSharpUserInfoStatLevelUsesProtocolField(t *testing.T) {
character := dnfrepo.CharacterRecord{
Level: newCharacterInitialLevel,
Stats: map[string]int64{"stat_level": int64(newCharacterInitialLevel)},
}
if got := csharpUserInfoStatLevel(nil, character); got != csharpSubtype1ProtocolStatLevel {
t.Fatalf("stat level from old Go level fallback = %d, want %d", got, csharpSubtype1ProtocolStatLevel)
}
if got := csharpUserInfoStatLevel(dnfrepo.LegacyUserInfoRow{"stat_level": "56"}, character); got != 56 {
t.Fatalf("legacy row stat level = %d, want 56", got)
}
character.Stats["stat_level"] = csharpSubtype1ProtocolStatLevel
if got := csharpUserInfoStatLevel(nil, character); got != csharpSubtype1ProtocolStatLevel {
t.Fatalf("stat level = %d, want %d", got, csharpSubtype1ProtocolStatLevel)
}
}