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

269 lines
10 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"testing"
"time"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestPVPPrivateCharacterWireFlags(t *testing.T) {
for _, private := range []bool{false, true} {
c := dnfrepo.CharacterRecord{CharacterID: "1", Name: "hero", Job: "0", Level: 85, Stats: map[string]int64{dnfpvp.PrivateCharacterKey: int64(boolByteValue(private, 1, 0))}}
var w packetWriter
writeNoPackRosterEntry(&w, 0, c)
tail := w.bytes()[noPackRosterPostEquipStart(0, c):]
if len(tail) != 38 || tail[34] != boolByteValue(private, 1, 0) || tail[27] != 255 || tail[37] != 0 {
t.Fatalf("roster=%x", tail)
}
raw, _ := buildCurrentSceneObjectRawState(c, true, "", "")
if raw[0x2a] != boolByteValue(private, 1, 0) || raw[0x2b] != 0 || raw[0x2c] != 1 {
t.Fatalf("actor=%x", raw)
}
}
}
func TestPVPPrivateCharacterRejectsInvalidRequest(t *testing.T) {
s := &Service{}
for _, body := range [][]byte{nil, make([]byte, 7), make([]byte, 9), {0, 1, 0, 0, 3, 0, 0, 0}, {0, 0, 0, 0, 6, 0, 0, 0}} {
conn := &bufferConn{}
if err := s.handleCheckPVPPrivateCharacter(&gameSession{conn: conn}, body); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.MsgID != 870 || !bytes.Equal(ack.Body, []byte{0, 2}) || len(rest) != 0 {
t.Fatalf("invalid request %x ack=%x", body, ack.Body)
}
}
conn := &bufferConn{}
frame, err := dnfproto.BuildChannelPacket(870, make([]byte, 8), 0, 0)
if err != nil {
t.Fatal(err)
}
frame[0] = 0 // BuildChannelPacket treats zero as the default command class.
if err := s.handleGameUpper(&gameSession{conn: conn}, frame); err != nil || conn.write.Len() != 0 {
t.Fatalf("class mismatch response=%x err=%v", conn.write.Bytes(), err)
}
}
func TestRealPVFPrivateCharacterAllProfessions(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
repos := memory.NewMemoryGroup()
s := &Service{options: options{accountPrefix: "dnf:"}, initialEquipmentArchive: archive, repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true }}
if err := s.preloadCharacterStatTable(ctx); err != nil {
t.Fatal(err)
}
text, err := archive.ReadText("etc/pvpparameter.etc")
if err != nil {
t.Fatal(err)
}
doc, err := dnfpvf.Parse("etc/pvpparameter.etc", text)
if err != nil {
t.Fatal(err)
}
pairs := doc.Ints("pvp matching possible character")
for i := 0; i < len(pairs); i += 2 {
job, grow := byte(pairs[i]), byte(pairs[i+1])
t.Run(fmt.Sprintf("job%d-grow%d", job, grow), func(t *testing.T) {
repos = memory.NewMemoryGroup()
logPath := filepath.Join(t.TempDir(), "creation.log")
logger, err := openPacketLogger(logPath)
if err != nil {
t.Fatal(err)
}
s.packetLog.Store(logger)
t.Cleanup(func() {
_ = logger.Close()
if t.Failed() {
data, _ := os.ReadFile(logPath)
t.Logf("creation log:\n%s", data)
}
})
plan, err := s.loadPVPPrivateCreation(job, grow)
if err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
// 注:参考实现此处还断言 session.levelAimingCreatedID == 0(专用角色不参与
// 预约升级)。本树没有预约升级链路,gameSession 也无该字段,故只测专用角色本身。
session := &gameSession{conn: conn}
body := make([]byte, 8)
binary.LittleEndian.PutUint32(body, uint32(job))
binary.LittleEndian.PutUint32(body[4:], uint32(grow))
if err := s.handleGameCommand(session, 1, 870, body); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.MsgID != 870 || ack.Header.Classification != 1 || !bytes.Equal(ack.Body, []byte{1, 0}) || len(rest) != 0 {
t.Fatalf("precheck=%+v %x", ack.Header, ack.Body)
}
conn.write.Reset()
frame, err := dnfproto.BuildChannelPacket(870, body, 0, 1)
if err != nil {
t.Fatal(err)
}
if err := s.handleGameUpper(session, frame); err != nil {
t.Fatal(err)
}
ack, _ = splitGameServerUpperPacket(t, conn.write.Bytes())
if !bytes.Equal(ack.Body, []byte{1, 0}) {
t.Fatalf("upper precheck=%x", ack.Body)
}
conn.write.Reset()
name := fmt.Sprintf("pvptest%dg%d", job, grow)
request := append(buildCreateRequest(job, name), 0, 0, 0, 0, 0, 0, grow, 0)
if err := s.handleUpperCreateCharacter(session, request); err != nil {
t.Fatal(err)
}
ack, _ = splitGameServerUpperPacket(t, conn.write.Bytes())
if ack.Header.MsgID != 5 || len(ack.Body) < 3 || ack.Body[0] != 1 {
t.Fatalf("create=%x", ack.Body)
}
id := fmt.Sprint(binary.LittleEndian.Uint16(ack.Body[1:3]))
c, ok, err := repos.Character.Load(ctx, id)
if err != nil || !ok {
t.Fatalf("load %s: %v", id, err)
}
if c.Level != plan.Level || c.Job != fmt.Sprint(job) || c.Stats["grow_type"] != int64(grow) || !dnfpvp.IsPrivateCharacter(c) || c.Stats["tutorial_completed"] != 1 {
t.Fatalf("character=%+v", c)
}
if c.Stats["stat_hp_max"] <= 0 || c.Stats["stat_mp_max"] <= 0 {
t.Fatalf("private stats: %+v", c.Stats)
}
equip, _, err := repos.Equipment.Load(ctx, id)
if err != nil {
t.Fatal(err)
}
bag, _, err := repos.Inventory.Load(ctx, id)
if err != nil {
t.Fatal(err)
}
wantEquip, wantBag := 0, 0
for _, item := range plan.Equipment {
if item.Equipped {
wantEquip++
slot := initialEquipmentSlotMap[item.Slot]
if equip.Entries[fmt.Sprint(slot)].ItemID != item.ItemID {
t.Fatalf("missing equipped %+v", item)
}
} else {
wantBag++
if bag.Slots[fmt.Sprintf("0:%d", 8+wantBag)].ItemID != item.ItemID {
t.Fatalf("missing bag %+v", item)
}
}
}
if len(equip.Entries) != wantEquip || len(bag.Slots) != wantBag+1 {
t.Fatalf("equipment=%d bag=%d", len(equip.Entries), len(bag.Slots))
}
owner, err := dnfequip.NewOwnerWithPlacementValidator(repos, dnfequip.PlacementValidatorFunc(func(context.Context, dnfequip.Placement) error { return nil }))
if err != nil {
t.Fatal(err)
}
for key, stack := range bag.Slots {
if stack.Extra["source"] != "pvf_pvp_private_create_equipment_list" {
continue
}
if len(stack.RawEntry) != 119 {
t.Fatalf("spare %s has no complete item row: %x", key, stack.RawEntry)
}
_, slot, _ := parseSceneInventorySlotKey(key)
cmd := dnfequip.MoveCommand{SelectedCharacterID: binary.LittleEndian.Uint16(ack.Body[1:3]), SourceListType: 0, SourceSlotIndex: slot,
SourceInstanceValue: int32(stack.ItemID), MoveCount: 1, DestinationListType: 3, DestinationSlotIndex: 12}
if _, err := owner.Move(ctx, cmd); err != nil {
t.Fatalf("equip spare %s: %v", key, err)
}
cmd.SourceSlotIndex = 63
cmd.SourceInstanceValue, cmd.DestinationInstanceValue = 0, int32(stack.ItemID)
if _, err := owner.Move(ctx, cmd); err != nil {
t.Fatalf("unequip spare %s: %v", key, err)
}
cmd.SourceInstanceValue, cmd.DestinationInstanceValue = int32(stack.ItemID), 0
if _, err := owner.Move(ctx, cmd); err != nil {
t.Fatalf("reequip spare %s: %v", key, err)
}
}
skill, ok, err := repos.Skill.Load(ctx, id)
if err != nil || !ok {
t.Fatalf("skill: %v", err)
}
for tree := 0; tree < 2; tree++ {
state, ok := dnfrepo.SkillTree(skill, tree)
if !ok || len(state.Skills) == 0 || state.Points.TotalSP <= 0 {
t.Fatalf("skill tree%d=%+v", tree, state)
}
}
if _, err := plan.Check(ctx, repos.Character, c.AccountID, time.Now()); err != dnfpvp.PrivateCreateError(150) {
t.Fatalf("duplicate check=%v", err)
}
conn.write.Reset()
if err := s.handleCheckPVPPrivateCharacter(session, body); err != nil {
t.Fatal(err)
}
ack, _ = splitGameServerUpperPacket(t, conn.write.Bytes())
if !bytes.Equal(ack.Body, []byte{0, 150}) {
t.Fatalf("duplicate ack=%x", ack.Body)
}
t.Logf("level=%d equipment=%d bag=%d SP=%d skills=%d", c.Level, wantEquip, wantBag, skill.Points.TotalSP, len(skill.Skills))
})
}
t.Run("ordinary-six-options", func(t *testing.T) {
repos = memory.NewMemoryGroup()
conn := &bufferConn{}
session := &gameSession{conn: conn}
request := append(buildCreateRequest(0, "ordinary"), 0, 0, 0, 0, 0, 255)
if err := s.handleUpperCreateCharacter(session, request); err != nil {
t.Fatal(err)
}
ack, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
if len(ack.Body) < 3 || ack.Body[0] != 1 {
t.Fatalf("ordinary create=%x", ack.Body)
}
c, ok, err := repos.Character.Load(ctx, "1")
// 参考实现还断言 session.levelAimingCreatedID == 1(普通角色登记预约升级)。
// 本树没有预约升级链路,故只断言"这是普通角色"。
if err != nil || !ok || c.Level != newCharacterInitialLevel || dnfpvp.IsPrivateCharacter(c) || c.Stats["grow_type"] != 0 {
t.Fatalf("ordinary=%+v err=%v", c, err)
}
})
t.Run("legacy-private-inherits-deleted-RP", func(t *testing.T) {
repos = memory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "40", AccountID: "dnf:1", Job: "14", Level: 85,
Stats: map[string]int64{"grow_type": 4, "delete_flag": 1, dnfpvp.PrivateCharacterKey: 1, dnfpvp.RankPointKey: 1388}, UpdatedAt: time.Now().Add(-31 * 24 * time.Hour)}); err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
if err := s.handleGameCreateCharacter(&gameSession{conn: conn}, append(buildCreateRequest(14, "legacyprivate"), 0, 0, 0, 0, 0, 4)); err != nil {
t.Fatal(err)
}
packets, err := splitWrittenGameRecords(conn.write.Bytes())
if err != nil || len(packets) < 2 || !bytes.Equal(packets[0].Body, []byte{1}) {
t.Fatalf("legacy packets=%+v err=%v", packets, err)
}
c, ok, err := repos.Character.Load(ctx, "41")
if err != nil || !ok || !dnfpvp.IsPrivateCharacter(c) || c.Stats[dnfpvp.RankPointKey] != 1388 {
t.Fatalf("inherited=%+v err=%v", c, err)
}
})
}