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