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,300 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"fmt"
"os"
"reflect"
"testing"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func warOperationSource() dungeonDropCatalogTestSource {
text := "[guardian list]\n[guardian]\n1\n[ap]\n50000\n[gp]\n1000\n"
for i := 1; i <= 5; i++ {
text += fmt.Sprintf("[skill_%d]\n0 1 5\n", i)
}
text += "[/guardian]\n[/guardian list]\n[guardian exp]\n1 18 0 2 83 0 3 237 20\n[/guardian exp]\n[guardian skill]\n1 1500 0 2 15000 200 3 50000 1000 4 100000 5000\n[/guardian skill]\n[shop list]\n201 202 211 212\n[/shop list]\n[building upgrade]\n[building]\n0 0 1000 0\n[/building]\n[mecenary]\n0 0 1000 0\n[/mecenary]\n[/building upgrade]"
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "", "equipment/equipment.lst": "",
"etc/agitwar.etc": text,
"npc/npc.lst": "390 `workshop.npc` 389 `pub.npc`",
"npc/workshop.npc": "[guild agit shop npc]\n`defence`\n[upgrade]\n`guild agit` 1 421 2 422\n[/upgrade]",
"npc/pub.npc": "[guild agit shop npc]\n`mercenary`\n[upgrade]\n`guild agit` 1 411 2 412\n[/upgrade]",
"itemshop/itemshop.lst": "201 `workshop.shp` 202 `workshop_2.shp` 211 `pub.shp` 212 `pub_2.shp`",
"stackable/stackable.lst": "10161523 `cannon.stk` 10161524 `cannon2.stk`",
"stackable/cannon.stk": "[name]\n`cannon`\n[stackable type]\n`[expert town potion]` 0\n[stack limit]\n2\n[require ap]\n7\n[minimum level]\n17",
"stackable/cannon2.stk": "[name]\n`cannon2`\n[stackable type]\n`[expert town potion]` 0\n[stack limit]\n2\n[require ap]\n9\n[minimum level]\n17",
}
for _, row := range []struct {
path string
npc, item int
}{{"workshop.shp", 421, 10161523}, {"workshop_2.shp", 422, 10161524}, {"pub.shp", 411, 10161523}, {"pub_2.shp", 412, 10161524}} {
source["itemshop/"+row.path] = fmt.Sprintf("[NPC]\n%d\n[type]\n`[agitwar build shop]`\n[item list]\n%d\n[/item list]", row.npc, row.item)
}
return source
}
func TestCurrentGuildWarOperationsCapturedRequests(t *testing.T) {
for _, legacy := range []bool{false, true} {
t.Run(fmt.Sprint(legacy), func(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
fail := &guildFailSettlement{base: repos.CharacterSettlement}
repos.CharacterSettlement = fail
master := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "dnf:master", Name: "master", Level: 90, Stats: map[string]int64{"gold": 10000}}
if err := repos.Character.Save(ctx, master); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "16", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
r := dnfguild.NewRegistry()
g, err := r.Create(master, "war", serviceTestNow())
if err != nil {
t.Fatal(err)
}
g.Guardians = []dnfguild.Guardian{{ID: 1, Level: 1}}
g.ActiveGuardian = 1
g.GuardianAP = 10000
g.GuardianGP = 2000
g.Mark = 13
if err = dnfguild.Save(ctx, repos.Settings, r, serviceTestNow()); err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(warOperationSource())
if err != nil {
t.Fatal(err)
}
ss := &gameSession{conn: &bufferConn{}, selectedCharacterID: 16, accountID: master.AccountID, initialTownRouteCharacterID: 16, initialTownRouteStage: currentInitialTownRoutePlayerStateSent}
s := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}, pvfItemCatalog: items, repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true }}
load := func() (*dnfguild.Guild, dnfrepo.InventoryRecord) {
t.Helper()
r, e := dnfguild.Load(ctx, repos.Settings)
if e != nil {
t.Fatal(e)
}
g, _ := r.GuildByID(g.ID)
inv, ok, e := repos.Inventory.Load(ctx, "16")
if e != nil || !ok {
t.Fatal(e)
}
return g, inv
}
send := func(op uint16, body []byte, code byte) []byte {
t.Helper()
ss.conn.(*bufferConn).write.Reset()
h := guildGameplayModule().UpperHandlers[op]
if legacy {
h = guildGameplayModule().LegacyHandlers[op]
}
if e := h(s, ss, gameplayRequest{Opcode: op, Classification: 1, Body: body}); e != nil {
t.Fatal(e)
}
ack, rest := splitGameServerUpperPacket(t, ss.conn.(*bufferConn).write.Bytes())
want := []byte{1}
if code != 0 {
want = []byte{0, code}
}
if ack.Header.MsgID != op || ack.Header.Classification != 1 || !bytes.Equal(ack.Body, want) {
t.Fatalf("op%d ACK %x want %x", op, ack.Body, want)
}
if code != 0 && len(rest) > 0 {
t.Fatal("failure sent state")
}
return rest
}
words := func(v ...uint32) []byte {
b := make([]byte, 4*len(v))
for i, n := range v {
binary.LittleEndian.PutUint32(b[i*4:], n)
}
return b
}
feed, _ := hex.DecodeString("0000000001000000000000000b000000")
skill, _ := hex.DecodeString("02000000010000000000000001000000")
upgrade, _ := hex.DecodeString("438bdf8180f71a00b8f61a00c4860100000100000002000000")
buy, _ := hex.DecodeString("730d9b0001000000c9000000a5010000")
send(1350, feed, 0)
send(1350, feed, 0)
a, _ := load()
if a.GuardianAP != 9989 || a.Guardians[0].APExp != 11 || a.Guardians[0].Level != 1 {
t.Fatal("partial feed/retry")
}
send(1350, words(0, 1, 11, 7), 0)
a, _ = load()
if a.GuardianAP != 9982 || a.Guardians[0].APExp != 18 || a.Guardians[0].Level != 2 {
t.Fatal("level transition lost cumulative experience")
}
send(1350, skill, 0)
send(1350, skill, 0)
a, _ = load()
if a.Guardians[0].Skills[0] != 1 || a.GuardianAP != 9982 {
t.Fatal("free initial skill unlock/retry")
}
send(1350, words(2, 1, 1, 2), 0)
send(1350, words(2, 1, 1, 2), 0)
a, _ = load()
if a.GuardianAP != 8482 || a.Guardians[0].Skills[0] != 2 {
t.Fatal("skill level cost/retry")
}
send(1350, words(3, 1, 0, 1), 3) // second skill needs the second star
send(1350, words(0, 1, 0, 1), 3) // stale experience
send(1350, words(0, 1, 18, 1000), 3) // past current threshold
send(1350, words(2, 1, 2, 3), 22) // actual AP/GP cost exceeds balance
before, invBefore := load()
fail.fail = true
send(1354, buy, 3)
fail.fail = false
a, inv := load()
if !reflect.DeepEqual(a, before) || !reflect.DeepEqual(inv, invBefore) {
t.Fatal("rolled-back purchase changed guild or inventory")
}
rest := send(1354, buy, 0)
itemPacket, rest := splitGameServerUpperPacket(t, rest)
state, tail := splitGameServerUpperPacket(t, rest)
if itemPacket.Header.MsgID != 14 || state.Header.MsgID != 1297 || len(tail) != 0 {
t.Fatal("purchase refresh order")
}
a, inv = load()
count := int64(0)
for _, item := range inv.Slots {
if item.ItemID == 10161523 {
count += item.Count
}
}
if a.GuardianAP != 8475 || count != 1 {
t.Fatal("purchase AP debit/grant")
}
// Failed upgrade rolls back level and balance; retry succeeds once.
before, _ = load()
fail.fail = true
send(840, upgrade, 3)
fail.fail = false
a, _ = load()
if !reflect.DeepEqual(a, before) {
t.Fatal("failed upgrade persisted")
}
rest = send(840, upgrade, 0)
state, rest = splitGameServerUpperPacket(t, rest)
notice, tail := splitGameServerUpperPacket(t, rest)
if state.Header.MsgID != 1297 || binary.LittleEndian.Uint16(state.Body[2166:]) != 2 || binary.LittleEndian.Uint16(state.Body[2168:]) != 1 || notice.Header.MsgID != 778 || !bytes.Equal(notice.Body, []byte{0, 2, 0, 0, 0}) || len(tail) != 0 {
t.Fatal("native merchant level projection")
}
send(840, upgrade, 0)
a, _ = load()
if a.GuardianAP != 7475 || a.WarShopLevel(0) != 2 || a.Mark != 13 {
t.Fatal("upgrade retry or guild state")
}
send(1354, buy, 3) // stale level-1 shop after upgrade
send(1354, words(10161524, 3, 202, 422), 0)
a, inv = load()
count = 0
for _, item := range inv.Slots {
if item.ItemID == 10161524 {
count += item.Count
if item.Count > 2 {
t.Fatal("stack overflow")
}
}
}
if a.GuardianAP != 7448 || count != 3 {
t.Fatal("upgraded shop item grant")
}
// A partial existing stack must roll back too if the remainder has
// no room. The failure ACK releases the native purchase-busy flag.
inv.Slots = fullCurrentNPCShopWasteInventory()
inv.Slots[currentCeraShopInventorySlotKey(0, 65)] = dnfrepo.ItemStack{ItemID: 10161524, Count: 1}
if e := repos.Inventory.Save(ctx, inv); e != nil {
t.Fatal(e)
}
before, invBefore = load()
send(1354, words(10161524, 3, 202, 422), 136)
a, inv = load()
if !reflect.DeepEqual(a, before) || !reflect.DeepEqual(inv, invBefore) {
t.Fatal("full backpack purchase changed guild or partial stack")
}
for _, body := range [][]byte{nil, upgrade[:24], append(append([]byte{}, upgrade...), 0)} {
send(840, body, 3)
}
send(1354, words(10161524, 0, 202, 422), 3)
send(1354, words(10161524, 1, 202, 411), 3)
ss.accountID = "dnf:wrong"
send(1354, words(10161524, 1, 202, 422), 3)
ss.accountID = master.AccountID
stored, e := dnfguild.Load(ctx, repos.Settings)
if e != nil {
t.Fatal(e)
}
storedGuild, _ := stored.GuildByID(g.ID)
member := storedGuild.Members["16"]
member.Grade = 5
storedGuild.Members["16"] = member
if e = dnfguild.Save(ctx, repos.Settings, stored, serviceTestNow()); e != nil {
t.Fatal(e)
}
send(1354, words(10161524, 1, 202, 422), 36)
send(840, upgrade, 36)
c, _, e := repos.Character.Load(ctx, "16")
if e != nil || c.Stats["gold"] != 10000 {
t.Fatal("guild shop changed gold")
}
})
}
}
func TestCurrentGuildWarRealPVFCatalog(t *testing.T) {
name := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if name == "" {
t.Skip("real PVF not selected")
}
archive, err := platformpvf.Open(name)
if err != nil {
t.Fatal(err)
}
growth, err := dnfguild.LoadGuardianGrowth(archive)
if err != nil {
t.Fatal(err)
}
if len(growth.Experience) != 49 || growth.Experience[1] != (dnfguild.GuardianCost{AP: 18}) || growth.Experience[20] != (dnfguild.GuardianCost{AP: 66247, GP: 604}) || growth.Skills[1].AP != 1500 || growth.Skills[0] != (dnfguild.GuardianCost{}) || len(growth.SkillLimits) != 2 {
t.Fatal("actual guardian rules")
}
shops, err := dnfguild.LoadWarShops(archive)
if err != nil {
t.Fatal(err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
for kind, merchant := range shops {
if len(merchant.Levels) != 10 || merchant.Costs[2].AP != 1000 || merchant.Costs[10] != (dnfguild.GuardianCost{AP: 95000, GP: 5000}) {
t.Fatalf("actual merchant%d", kind)
}
for _, shop := range merchant.Levels {
for item := range shop.Items {
def, e := items.ResolveItem(item)
if e != nil {
t.Fatal(e)
}
if _, e = dnfguild.LoadWarProductCost(archive, def.PVFPath); e != nil {
t.Fatalf("product%d: %v", item, e)
}
}
}
}
if !shops[0].Levels[1].Items[10161523] || shops[0].Levels[1].NPC != 421 || shops[0].Levels[1].ID != 201 {
t.Fatal("captured cannon context")
}
cost, err := dnfguild.LoadWarProductCost(archive, "stackable/10161001/10161523.stk")
if err != nil || cost != (dnfguild.GuardianCost{AP: 7}) {
t.Fatal("actual cannon price", cost, err)
}
}