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

320 lines
11 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"os"
"reflect"
"strconv"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/jobmap"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
memory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealPVFLevelAimingWeaponSelectionKeepsUpgrade(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("real PVF path unset")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
repositories := memory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1", Job: "0", Level: 20}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:95": {ItemID: 490003551, Count: 1}}}); err != nil {
t.Fatal(err)
}
service := currentBoosterTestService(items, repositories)
conn := &bufferConn{}
session := &gameSession{conn: conn, connID: "level-weapon", selectedCharacterID: 19}
body := make([]byte, 10)
binary.LittleEndian.PutUint16(body, 95)
binary.LittleEndian.PutUint32(body[4:], 401000041)
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketUseBoosterItem), body); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(ack.Body) == 0 || ack.Body[0] != 1 {
t.Fatalf("open rejected: %x", ack.Body)
}
inv, ok, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !ok {
t.Fatal(err)
}
if _, ok := inv.Slots["0:95"]; ok {
t.Fatal("source box was not consumed")
}
found := false
for key, stack := range inv.Slots {
if stack.ItemID != 401000041 {
continue
}
found = true
if stack.Extra["upgrade_level"] != "11" || len(stack.RawEntry) != currentItemListEntryWireSize || stack.RawEntry[0x0A] != 11 {
t.Fatalf("native +11 reward lost reinforcement: slot=%s extra=%v raw=%x", key, stack.Extra, stack.RawEntry)
}
}
if !found {
t.Fatal("selected weapon missing")
}
wire := false
for len(rest) > 0 {
packet, tail := splitCurrentGameServerUpperPacketAuto(t, rest)
rest = tail
if packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || len(packet.Body) < 5 {
continue
}
for i := 5; i+currentItemListEntryWireSize <= len(packet.Body); i += currentItemListEntryWireSize {
row := packet.Body[i : i+currentItemListEntryWireSize]
if binary.LittleEndian.Uint32(row[2:6]) == 401000041 {
wire = true
if row[0x0A] != 11 {
t.Fatalf("wire reinforcement=%d", row[0x0A])
}
}
}
}
if !wire {
t.Fatal("weapon inventory update missing")
}
}
func TestBoosterSelectionUpgradeCategoryIsolation(t *testing.T) {
doc, err := dnfpvf.Parse("selection.stk", "[booster select category]\n0 0\n[booster equipment upgrade]\n11\n[equipment]\n100 1\n[/equipment]\n[stackable]\n200 1\n[/stackable]\n[booster select category]\n1 0\n[equipment]\n100 1\n[/equipment]\n")
if err != nil {
t.Fatal(err)
}
_, categories, err := parseCurrentBoosterSelectionCatalog(doc)
if err != nil {
t.Fatal(err)
}
if categories[0][0].UpgradeLevel != 11 || categories[0][1].UpgradeLevel != 0 || categories[1][0].UpgradeLevel != 0 {
t.Fatalf("upgrade leaked between categories or into stackable: %+v", categories)
}
for _, value := range []string{"-1", "256", "11 12", ""} {
doc, err := dnfpvf.Parse("selection.stk", "[booster select category]\n0 0\n[booster equipment upgrade]\n"+value+"\n[equipment]\n100 1\n[/equipment]\n")
if err != nil {
t.Fatal(err)
}
if _, _, err := parseCurrentBoosterSelectionCatalog(doc); err == nil {
t.Fatalf("invalid upgrade accepted: %q", value)
}
}
}
// Exercise the native op160 writer shapes against the candidate PVF and an
// isolated repository, including real persistence and the inventory notification.
func openLevelAimingTestBox(t *testing.T, items *pvfDungeonDropCatalog, job int, source uint32, body []byte, want map[uint32]uint32, upgrades map[uint32]byte) {
t.Helper()
repos := memory.NewMemoryGroup()
ctx := context.Background()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1", Job: strconv.Itoa(job), Level: 90}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:95": {ItemID: int64(source), Count: 1}}}); err != nil {
t.Fatal(err)
}
service := currentBoosterTestService(items, repos)
conn := &bufferConn{}
session := &gameSession{conn: conn, connID: "native-alljob", selectedCharacterID: 19}
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketUseBoosterItem), body); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(ack.Body) == 0 || ack.Body[0] != 1 {
t.Fatalf("source=%d ack=%x", source, ack.Body)
}
inv, ok, err := repos.Inventory.Load(ctx, "19")
if err != nil || !ok {
t.Fatal(err)
}
got := map[uint32]uint32{}
for key, stack := range inv.Slots {
got[uint32(stack.ItemID)] += uint32(stack.Count)
if level := upgrades[uint32(stack.ItemID)]; level > 0 {
if stack.Extra["upgrade_level"] != strconv.Itoa(int(level)) || len(stack.RawEntry) != currentItemListEntryWireSize || stack.RawEntry[0x0A] != level {
t.Fatalf("source=%d item=%d lost upgrade: %+v", source, stack.ItemID, stack)
}
if currentItemListStackExpire(stack) == 0 {
t.Fatalf("temporary weapon lost expiration: %s", key)
}
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("source=%d rewards=%v want=%v", source, got, want)
}
seen := map[uint32]byte{}
for len(rest) > 0 {
packet, tail := splitCurrentGameServerUpperPacketAuto(t, rest)
rest = tail
if packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || len(packet.Body) < 5 || packet.Body[0] != 0 {
continue
}
for i := 5; i+currentItemListEntryWireSize <= len(packet.Body); i += currentItemListEntryWireSize {
row := packet.Body[i : i+currentItemListEntryWireSize]
seen[binary.LittleEndian.Uint32(row[2:6])] = row[0x0A]
}
}
for id, level := range upgrades {
if actual, ok := seen[id]; !ok || actual != level {
t.Fatalf("item=%d wire upgrade=%d found=%t", id, actual, ok)
}
}
// A replay must not recreate the consumed reward source or duplicate contents.
conn.write.Reset()
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketUseBoosterItem), body); err != nil {
t.Fatal(err)
}
after, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(inv, after) {
t.Fatal("consumed box replay changed inventory")
}
}
func TestRealPVFLevelAimingAllJobNativeRewards(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("real PVF path unset")
}
a, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(a)
if err != nil {
t.Fatal(err)
}
weapons, err := resolveCurrentBoosterDefinition(items, 490003551, currentBoosterRequestSelection)
if err != nil {
t.Fatal(err)
}
if len(weapons.SelectionCategory) != 10 {
t.Fatalf("categories=%d want=10", len(weapons.SelectionCategory))
}
covered := map[int]bool{}
for category, candidates := range weapons.SelectionCategory {
for _, c := range candidates {
d, err := items.ResolveItem(c.ItemID)
if err != nil {
t.Fatal(err)
}
if c.UpgradeLevel != 11 || d.UsablePeriodDays != 30 {
t.Fatalf("weapon=%d upgrade=%d days=%d", c.ItemID, c.UpgradeLevel, d.UsablePeriodDays)
}
doc, err := parseDungeonCardPVFDocument(a, d.PVFPath)
if err != nil {
t.Fatal(err)
}
jobs, ok := doc.Section("usable job")
if !ok {
t.Fatal("missing usable job")
}
jobText := ""
for _, v := range jobs {
jobText += v.Value
}
for job := 0; job <= jobmap.MaxID; job++ {
if !jobmap.MatchesQuestTag(jobText, job) {
continue
}
covered[job] = true
t.Run(fmt.Sprintf("weapon/job%d/category%d/%d", job, category, c.ItemID), func(t *testing.T) {
body := make([]byte, 10)
binary.LittleEndian.PutUint16(body, 95)
binary.LittleEndian.PutUint16(body[2:], category)
binary.LittleEndian.PutUint32(body[4:], c.ItemID)
openLevelAimingTestBox(t, items, job, 490003551, body, map[uint32]uint32{c.ItemID: 1}, map[uint32]byte{c.ItemID: 11})
})
}
}
}
for job := 0; job <= jobmap.MaxID; job++ {
if !covered[job] {
t.Errorf("job %d has no native weapon", job)
}
}
armor, err := resolveCurrentBoosterDefinition(items, 490003554, currentBoosterRequestSelection)
if err != nil {
t.Fatal(err)
}
if armor.SelectionRequired != 0 || len(armor.SelectionCategory) != 5 {
t.Fatalf("armor=%+v", armor)
}
for category, candidates := range armor.SelectionCategory {
t.Run(fmt.Sprintf("armor/%d", category), func(t *testing.T) {
if len(candidates) != 5 {
t.Fatalf("armor count=%d", len(candidates))
}
body := make([]byte, 6+9*len(candidates))
binary.LittleEndian.PutUint16(body, 95)
binary.LittleEndian.PutUint16(body[2:], category)
want := map[uint32]uint32{}
n := len(candidates)
body[4+4*n] = byte(n)
for i, c := range candidates {
binary.LittleEndian.PutUint32(body[4+i*4:], c.ItemID)
binary.LittleEndian.PutUint32(body[5+4*n+i*5:], c.ItemID)
want[c.ItemID] = c.Count
}
openLevelAimingTestBox(t, items, 0, 490003554, body, want, nil)
})
}
for stage := 0; stage < 16; stage++ {
id := uint32(490003535 + stage)
def, err := resolveCurrentBoosterDefinition(items, id, currentBoosterRequestRandom)
if err != nil {
t.Fatal(err)
}
want := map[uint32]uint32{}
for _, group := range def.Random.Groups {
if len(group.Entries) != 1 {
t.Fatal("stage is not a fixed native reward")
}
v := group.Entries[0]
want[uint32(v.ItemID)] += uint32(v.Count)
}
if stage == 0 && !reflect.DeepEqual(want, map[uint32]uint32{490003532: 1, 490003551: 1, 490003552: 1}) {
t.Fatalf("Lv10=%v", want)
}
if stage == 2 && !reflect.DeepEqual(want, map[uint32]uint32{490003532: 1, 490003554: 1}) {
t.Fatalf("Lv20=%v", want)
}
for _, source := range []uint32{id, uint32(10154405 + stage)} {
t.Run(fmt.Sprintf("stage/%d", source), func(t *testing.T) {
d, err := items.ResolveItem(source)
if err != nil {
t.Fatal(err)
}
doc, err := parseDungeonCardPVFDocument(a, d.PVFPath)
if err != nil {
t.Fatal(err)
}
mark := doc.Ints("icon mark")
name, _ := doc.Text("name")
if len(mark) != 1 || mark[0] != 34 || strings.HasPrefix(name, "name_") {
t.Fatalf("bad native display: %s mark=%v", name, mark)
}
openLevelAimingTestBox(t, items, 0, source, []byte{95, 0}, want, nil)
})
}
}
}