DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,384 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/progression"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/wedding"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func weddingCapsuleTestService(t *testing.T) (*Service, dnfrepo.Group, *gameSession, *gameSession) {
t.Helper()
s, repos, a, b := weddingTestService(t)
source := bridgePVFSource{
"monster/monster.lst": "", "equipment/equipment.lst": "",
"stackable/stackable.lst": "10002613 `ring.stk`\n10002910 `capsule.stk`",
"stackable/ring.stk": "[stackable type] `[etc]` 1\n[stack limit] 1\n[price] 3500000\n[need material] 10002606 500\n[/need material]\n[action type] `[upgrade wedding ring]` 3\n[/action type]",
"stackable/capsule.stk": "[stackable type] `[waste]` 0\n[stack limit] 1000\n[attach type] `[account]`\n[minimum level] 30\n[maximum level] 84\n[increase status type] `[expUp]` 30000",
"itemshop/itemshop.lst": "1207 `wedding.shp`", "itemshop/wedding.shp": "[item list] 10002613\n[/item list]",
"equipment/pricetable.tbl": "[rate] 200 150 30\n[repair cost] 0.08",
}
var err error
s.pvfItemCatalog, err = newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
s.npcShopCatalog, err = newCurrentNPCShopCatalog(source)
if err != nil {
t.Fatal(err)
}
s.weddingCatalog.RingTickets[3] = wedding.Item{ID: 10002613, Count: 1}
s.weddingCapsuleCatalog = &wedding.CapsuleCatalog{MaxOwn: 10, MaxTaken: 10, Gauge: 2, MaxGauge: 10, ItemID: 10002910}
ctx := context.Background()
for i, id := range []string{"1", "2"} {
c, _, _ := repos.Character.Load(ctx, id)
c.Stats = map[string]int64{"wedding_state": 4, "wedding_id": 65538, "wedding_partner_id": int64(2 - i), "wedding_date": 123, "wedding_love": 946, "wedding_ring": 2, "gold": 9000000, "wedding_capsule_pair": 65538, "wedding_capsule_date": 123, "wedding_capsule_progress": 10}
if err := repos.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: id, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
a.townPositionSnapshot.TownID = 101
b.townPositionSnapshot.TownID = 101
return s, repos, a, b
}
func TestWeddingCapsuleNativeWorkflowAndRestore(t *testing.T) {
for _, upper := range []bool{false, true} {
t.Run(strconv.FormatBool(upper), func(t *testing.T) {
s, repos, a, b := weddingCapsuleTestService(t)
send := func(peer *gameSession, op uint16, code int32) []byte {
t.Helper()
peer.conn.(*bufferConn).write.Reset()
m := weddingGameplayModule()
r := gameplayRequest{Classification: dnfproto.DefaultChannelClassification}
var err error
if upper {
err = m.UpperHandlers[op](s, peer, r)
} else {
r.Body = m.LegacyNormalizers[op]([]byte{1, 2, 3, 4})
err = m.LegacyHandlers[op](s, peer, r)
}
if err != nil {
t.Fatal(err)
}
ack, rest := splitLongHengGameServerUpperPacket(t, peer.conn.(*bufferConn).write.Bytes())
if ack.Header.MsgID != op || ack.Body[0] != 1 || int32(binary.LittleEndian.Uint32(ack.Body[1:5])) != code {
t.Fatalf("ack=%+v %x", ack.Header, ack.Body)
}
if code != 0 && len(ack.Body) != 5 {
t.Fatalf("failure has success fields %x", ack.Body)
}
return rest
}
if rest := send(a, 1052, 0); len(rest) != 0 {
t.Fatalf("make extra=%x", rest)
}
ack, _ := splitLongHengGameServerUpperPacket(t, a.conn.(*bufferConn).write.Bytes())
if !bytes.Equal(ack.Body, []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 1}) {
t.Fatalf("make layout=%x", ack.Body)
}
send(a, 1052, -4)
send(a, 1053, 0)
pending, rest := splitLongHengGameServerUpperPacket(t, b.conn.(*bufferConn).write.Bytes())
if pending.Header.MsgID != 1015 || !bytes.Equal(pending.Body, []byte{10, 0, 0, 0, 0, 1, 0}) || len(rest) != 0 {
t.Fatalf("pending=%x rest=%x", pending.Body, rest)
}
// A new service restores solely from repositories, including the sender reminder.
for _, peer := range []*gameSession{a, b} {
peer.conn.(*bufferConn).write.Reset()
c, _, _ := repos.Character.Load(context.Background(), strconv.Itoa(int(peer.selectedCharacterID)))
restored := &Service{options: s.options, weddingCatalog: s.weddingCatalog, weddingCapsuleCatalog: s.weddingCapsuleCatalog, repositoryProvider: s.repositoryProvider}
if err := restored.sendWeddingCapsuleState(peer, c); err != nil {
t.Fatal(err)
}
noti, _ := splitLongHengGameServerUpperPacket(t, peer.conn.(*bufferConn).write.Bytes())
if noti.Header.MsgID != 1015 || len(noti.Body) != 7 {
t.Fatalf("restore=%+v", noti)
}
if peer == a && noti.Body[6] != 1 || peer == b && noti.Body[5] != 1 {
t.Fatalf("restore state=%x", noti.Body)
}
}
send(a, 1053, -4)
rest = send(b, 1054, 0)
item, rest := splitLongHengGameServerUpperPacket(t, rest)
if item.Header.MsgID != 13 || len(rest) == 0 {
t.Fatalf("take inventory order=%+v", item.Header)
}
bag, _, _ := repos.Inventory.Load(context.Background(), "2")
assertCurrentNPCShopInventoryAmount(t, bag, 10002910, 1)
send(b, 1054, -1)
})
}
}
func TestWeddingCapsuleRejectsMalformedClassDungeonAndFullBag(t *testing.T) {
s, repos, a, _ := weddingCapsuleTestService(t)
m := weddingGameplayModule()
for _, op := range []uint16{1052, 1053, 1054} {
if err := m.UpperHandlers[op](s, a, gameplayRequest{Classification: 0, Body: nil}); err != nil {
t.Fatal(err)
}
if err := m.UpperHandlers[op](s, a, gameplayRequest{Classification: dnfproto.DefaultChannelClassification, Body: []byte{1}}); err != nil {
t.Fatal(err)
}
}
if a.conn.(*bufferConn).write.Len() != 0 {
t.Fatal("invalid requests sent reply")
}
a.dungeon.runtime = &runtimeDungeonState{}
if err := s.weddingCapsuleCommand(a, 1052, nil); err != nil {
t.Fatal(err)
}
ack, _ := splitLongHengGameServerUpperPacket(t, a.conn.(*bufferConn).write.Bytes())
if int32(binary.LittleEndian.Uint32(ack.Body[1:])) != -6 {
t.Fatalf("dungeon ack=%x", ack.Body)
}
a.dungeon.runtime = nil
a.conn.(*bufferConn).write.Reset()
c, _, _ := repos.Character.Load(context.Background(), "1")
c.Stats["wedding_capsule_taken"] = 2
if err := repos.Character.Save(context.Background(), c); err != nil {
t.Fatal(err)
}
bag := dnfrepo.InventoryRecord{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{}}
for slot := 1; slot <= 400; slot++ {
bag.Slots["0:"+strconv.Itoa(slot)] = dnfrepo.ItemStack{ItemID: 999, Count: 1}
}
if err := repos.Inventory.Save(context.Background(), bag); err != nil {
t.Fatal(err)
}
if err := s.weddingCapsuleCommand(a, 1054, nil); err != nil {
t.Fatal(err)
}
ack, _ = splitLongHengGameServerUpperPacket(t, a.conn.(*bufferConn).write.Bytes())
if int32(binary.LittleEndian.Uint32(ack.Body[1:])) != -6 {
t.Fatalf("full bag ack=%x", ack.Body)
}
c, _, _ = repos.Character.Load(context.Background(), "1")
if c.Stats["wedding_capsule_taken"] != 2 {
t.Fatal("full bag lost gift")
}
}
func TestWeddingRingShopNativePurchase(t *testing.T) {
for _, mode := range []string{"success", "gold", "love", "full_bag"} {
t.Run(mode, func(t *testing.T) {
s, repos, a, b := weddingCapsuleTestService(t)
ctx := context.Background()
if mode == "gold" {
c, _, _ := repos.Character.Load(ctx, "1")
c.Stats["gold"] = 1
if err := repos.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
}
if mode == "love" {
for _, id := range []string{"1", "2"} {
c, _, _ := repos.Character.Load(ctx, id)
c.Stats["wedding_love"] = 1
if err := repos.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
}
}
if mode == "full_bag" {
bag := dnfrepo.InventoryRecord{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{}}
for slot := 1; slot <= 400; slot++ {
bag.Slots["0:"+strconv.Itoa(slot)] = dnfrepo.ItemStack{ItemID: 999, Count: 1}
}
if err := repos.Inventory.Save(ctx, bag); err != nil {
t.Fatal(err)
}
}
if err := s.handleCurrentNPCShopBuy(a, currentNPCShopBuyTestBody(10002613, 1, 1207, 20011)); err != nil {
t.Fatal(err)
}
ack, rest := splitLongHengGameServerUpperPacket(t, a.conn.(*bufferConn).write.Bytes())
if mode != "success" {
want := map[string]byte{"gold": 10, "love": 1, "full_bag": 4}[mode]
if !bytes.Equal(ack.Body, []byte{0, want}) || len(rest) != 0 {
t.Fatalf("%s failure=%x rest=%x", mode, ack.Body, rest)
}
return
}
if ack.Body[0] != 1 || binary.LittleEndian.Uint32(ack.Body[1:]) != 5500000 {
t.Fatalf("buy=%x", ack.Body)
}
update, rest := splitLongHengGameServerUpperPacket(t, rest)
if update.Header.MsgID != 14 {
t.Fatal("missing item update")
}
love, rest := splitLongHengGameServerUpperPacket(t, rest)
if love.Header.MsgID != 1003 || binary.LittleEndian.Uint32(love.Body[4:]) != 446 || len(rest) != 0 {
t.Fatalf("love=%x", love.Body)
}
peer, _ := splitLongHengGameServerUpperPacket(t, b.conn.(*bufferConn).write.Bytes())
if peer.Header.MsgID != 1003 || binary.LittleEndian.Uint32(peer.Body[4:]) != 446 {
t.Fatalf("peer love=%x", peer.Body)
}
bag, _, _ := repos.Inventory.Load(ctx, "1")
assertCurrentNPCShopInventoryAmount(t, bag, 10002613, 1)
assertCurrentNPCShopInventoryAmount(t, bag, 10002606, 0)
})
}
}
func TestWeddingCapsuleAndRingRealPVF(t *testing.T) {
path := os.Getenv("DNF90_PVF_PATH")
if path == "" {
t.Skip("set DNF90_PVF_PATH for wedding capsule runtime verification")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
s, repos, a, b := weddingCapsuleTestService(t)
s.weddingCatalog, err = wedding.LoadCatalog(archive)
if err != nil {
t.Fatal(err)
}
s.weddingCapsuleCatalog = nil
s.initialEquipmentArchive = archive
rules, err := s.currentWeddingCapsuleCatalog()
if err != nil || *rules != (wedding.CapsuleCatalog{MaxOwn: 10, MaxTaken: 10, Gauge: 2, MaxGauge: 10, ItemID: 10002910}) {
t.Fatalf("rules=%+v %v", rules, err)
}
s.pvfItemCatalog, err = newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
s.npcShopCatalog, err = newCurrentNPCShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
for i, id := range []uint32{10002611, 10002612, 10002613, 10002614, 10002615} {
p, err := resolveCurrentNPCShopPricing(s.npcShopCatalog, s.pvfItemCatalog, id)
if err != nil || p.BuyGold != []int64{500000, 1000000, 3500000, 8000000, 12000000}[i] || p.NeedMaterialCount != []int64{50, 300, 500, 800, 999}[i] || p.NeedMaterialItem != 10002606 {
t.Fatalf("price %d=%+v %v", id, p, err)
}
}
if err := s.handleCurrentNPCShopBuy(a, currentNPCShopBuyTestBody(10002613, 1, 1207, 20011)); err != nil {
t.Fatal(err)
}
bag, _, _ := repos.Inventory.Load(context.Background(), "1")
assertCurrentNPCShopInventoryAmount(t, bag, 10002613, 1)
for _, step := range []struct {
session *gameSession
op uint16
}{{a, 1052}, {a, 1053}, {b, 1054}} {
if err := s.weddingCapsuleCommand(step.session, step.op, nil); err != nil {
t.Fatal(err)
}
}
claimed, _, _ := repos.Inventory.Load(context.Background(), "2")
assertCurrentNPCShopInventoryAmount(t, claimed, 10002910, 1)
resolver, err := s.alignedStackableUseResolverForCommand(dnfenum.CmdPacketUseStackable)
if err != nil {
t.Fatal(err)
}
r, err := resolver(10002910)
if err != nil || r.DirectExperience == nil || r.DirectExperience.MinimumLevel != 30 || r.DirectExperience.MaximumLevel != 84 {
t.Fatalf("capsule=%+v %v", r, err)
}
tables, err := progression.Load(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
for _, level := range []int{29, 30, 84, 85} {
exp, err := tables.ThresholdToNext(level - 1)
if err != nil {
t.Fatal(err)
}
plan, err := s.planDirectExperienceConsumable(tables, dnfrepo.CharacterRecord{CharacterID: "1", Level: level, Stats: map[string]int64{"exp": int64(exp)}}, dnfrepo.SkillRecord{CharacterID: "1", Points: dnfrepo.SkillPointState{SyncedLevel: level}}, *r.DirectExperience, time.Now())
if level == 29 || level == 85 {
if err == nil {
t.Fatalf("out-of-range %d accepted", level)
}
continue
}
if err != nil || plan.Gain != 30000 || plan.NewExperience != exp+30000 {
t.Fatalf("use level %d=%+v %v", level, plan, err)
}
}
// Exercise the bridge's real recommended-clear decision and catalog wiring,
// not just the module's arithmetic. Replayed settlement must preserve gauge.
service, runtime, session, _, _ := prepareCompletedSettlementRuntime(t)
service.initialEquipmentArchive = archive
repositories, _ := service.repositoryGroup()
c, _, _ := repositories.Character.Load(context.Background(), "99")
c.Level = 1
c.Stats["exp"] = 90
c.Stats["wedding_state"] = 4
c.Stats["wedding_id"] = 6488164
c.Stats["wedding_partner_id"] = 100
c.Stats["wedding_date"] = 123
if err := repositories.Character.Save(context.Background(), c); err != nil {
t.Fatal(err)
}
if err := repositories.Skill.Save(context.Background(), dnfrepo.SkillRecord{CharacterID: "99", Points: dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1}}); err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{AccountID: "account-1", Metadata: map[string]string{}}); err != nil {
t.Fatal(err)
}
runtime.Character = c
// 推荐副本判据读**进图那一刻**的等级快照 runtime.entryCharacterLevel,
// 所以这里改角色等级之后必须把快照一起刷成 1,才能落在 [1,1] 区间里。
runtime.Character.Level = 1
runtime.entryCharacterLevel = 1
runtime.ordinaryFinalRoomClearAccepted = true
runtime.Dungeon.Metadata.RecommendedLevels = []int64{1, 1}
resources := currentDungeonSettlementTestResources(t)
for i := 0; i < 2; i++ {
result, err := service.commitCurrentDungeonSettlement(context.Background(), session, runtime, resources.Progression, 20)
if err != nil || result.Character.Stats["wedding_capsule_progress"] != 2 || result.Replayed != (i == 1) {
t.Fatalf("bridge clear=%+v err=%v", result, err)
}
}
}
func TestWeddingCapsuleSettlementNotificationRetriesOnce(t *testing.T) {
service, runtime, session, _, _ := prepareCompletedSettlementRuntime(t)
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, 73)
plan.WeddingCapsuleBody = []byte{2, 0, 0, 0}
runtime.settlementResultPlan = &plan
request := make([]byte, currentDungeonPlayResultBaseSize)
request[currentDungeonPlayResultClientRankPointOffset] = 73
want := errors.New("capsule notification send failed")
conn := &failNthDungeonWriteConn{failAt: 4, err: want}
session.conn = conn
if err := service.handleDungeonSetPlayResult(session, request); !errors.Is(err, want) {
t.Fatalf("send=%v", err)
}
if runtime.settlementWeddingCapsuleSent || !runtime.settlementClearRewardSent {
t.Fatal("failed notification marked sent")
}
if err := service.handleDungeonSetPlayResult(session, request); err != nil {
t.Fatal(err)
}
if !runtime.settlementWeddingCapsuleSent {
t.Fatal("notification not retried")
}
written := conn.write.Len()
if err := service.handleDungeonSetPlayResult(session, request); err != nil {
t.Fatal(err)
}
if conn.write.Len() != written {
t.Fatal("completed notification duplicated")
}
}