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,216 @@
package dnfbridge
import (
"encoding/binary"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestAdventureGameplayModuleOwnsLegacyAndUpperRuntimeOpcodes(t *testing.T) {
module := adventureGameplayModule()
for _, opcode := range []uint16{
uint16(dnfenum.CmdPacketMercenaryInfo),
uint16(dnfenum.CmdPacketMercenaryCompetition),
uint16(dnfenum.CmdPacketMercenaryCompetitionCancle),
uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest),
uint16(dnfenum.CmdPacketMercenaryPointRecalculate),
uint16(dnfenum.CmdPacketAdventurerShopPurchase),
uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp),
} {
if module.LegacyHandlers[opcode] == nil || module.UpperHandlers[opcode] == nil {
t.Fatalf("opcode %d is not registered for both current transports", opcode)
}
}
}
func TestParseCurrentAdventureExpeditionStartRequest(t *testing.T) {
body := make([]byte, 13)
body[0] = 2
binary.LittleEndian.PutUint32(body[1:5], 6*60*60)
binary.LittleEndian.PutUint16(body[5:7], 2)
copy(body[7:], []byte{1, 0, 2, 3, 1, 4})
request, err := parseCurrentAdventureExpeditionStartRequest(body)
if err != nil {
t.Fatal(err)
}
if request.Area != 2 || request.DurationSeconds != 6*60*60 || len(request.Members) != 2 ||
request.Members[1] != (currentAdventureExpeditionMemberRequest{Selector: 3, Job: 1, GrowType: 4}) {
t.Fatalf("request=%+v", request)
}
if _, err := parseCurrentAdventureExpeditionStartRequest(body[:12]); err == nil {
t.Fatal("truncated request accepted")
}
}
func TestBuildCurrentAdventureExpeditionStateBodyMatchesCurrentReader(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
state := adventuregroup.RuntimeState{
Expeditions: map[string]adventuregroup.Expedition{
"2": {
Area: 2,
State: 1,
StartedAt: now.Unix() - 10,
EndsAt: now.Unix() + 10,
Attributes: []byte{4, 7},
Members: []adventuregroup.ExpeditionMember{{
CharacterID: 3,
Name: "牛牛",
Level: 90,
Job: 1,
GrowType: 2,
}},
},
},
}
body := buildCurrentAdventureExpeditionStateBody(state, now)
offset := 0
if body[offset] != 1 {
t.Fatalf("area count=%d", body[offset])
}
offset++
if body[offset] != 2 || body[offset+1] != 1 {
t.Fatalf("area/state=%x", body[offset:offset+2])
}
offset += 2 + 4 + 4
if body[offset] != 2 || body[offset+1] != 4 || body[offset+2] != 7 {
t.Fatalf("attributes=%x", body[offset:offset+3])
}
offset += 3
if binary.LittleEndian.Uint16(body[offset:offset+2]) != 1 {
t.Fatalf("member count=%x", body[offset:offset+2])
}
offset += 2
if binary.LittleEndian.Uint16(body[offset:offset+2]) != 3 {
t.Fatalf("member id=%x", body[offset:offset+2])
}
offset += 2
nameLength := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
if nameLength != 4 {
t.Fatalf("GB18030 name length=%d body=%x", nameLength, body)
}
}
func TestResolveCurrentAdventureShopProductAcceptsCurrentItemIDAndZeroBasedIndex(t *testing.T) {
category := adventuregroup.ShopCategory{Products: []adventuregroup.ShopProduct{
{ItemID: 100},
{ItemID: 200},
}}
if product, ok := resolveCurrentAdventureShopProduct(category, 200); !ok || product.ItemID != 200 {
t.Fatalf("item-id selector product=%+v ok=%v", product, ok)
}
if product, ok := resolveCurrentAdventureShopProduct(category, 0); !ok || product.ItemID != 100 {
t.Fatalf("index selector product=%+v ok=%v", product, ok)
}
}
// 冒险团商店直接卖传说灵魂(PVF [adventurer shop purchase info]:
// `10099775 15 70 3 3`),它的家是账号共享仓的 364 格。刷新报文必须报这一格 ——
// 只落库不发这一行,客户端要等下一次列表刷新才看得到买到的灵魂。
func TestSendCurrentAdventureInventoryUpdatesReportsAccountSharedSlot(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, connID: "adventure-shop-ack", selectedCharacterID: 22}
result := currentAdventureShopCommit{
Inventory: dnfrepo.InventoryRecord{CharacterID: "22", Slots: map[string]dnfrepo.ItemStack{}},
AccountInventory: dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{"0:364": {ItemID: 10099775, Count: 15}},
},
AccountSlots: []uint16{364},
}
if err := sendCurrentAdventureInventoryUpdates(service, session, result); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("adventure shop update packets=%v", finishBridgePacketIDs(packets))
}
body := packets[0].Body
if len(body) != 3+currentItemListEntryWireSize || body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(body[1:3]) != 1 {
t.Fatalf("adventure shop update body=%x", body)
}
row := body[3:]
if slot := binary.LittleEndian.Uint16(row[0:2]); slot != 364 {
t.Fatalf("adventure shop update slot=%d want the account soul cell 364", slot)
}
if itemID := binary.LittleEndian.Uint32(row[2:6]); itemID != 10099775 {
t.Fatalf("adventure shop update item=%d want 10099775", itemID)
}
if count := binary.LittleEndian.Uint32(row[6:10]); count != 15 {
t.Fatalf("adventure shop update count=%d want 15", count)
}
}
// 客户端 0x00C79940 的结果码跳转表只认 19/22/23/204/235;其余(含过去一律回的 1)
// 落到 default = 什么都不弹。这条用例把"超限没提示"钉死。
func TestCurrentAdventureShopFailureCodesMatchTheClientResultTable(t *testing.T) {
tests := []struct {
name string
err error
want byte
}{
{"purchase limit", adventuregroup.ErrPurchaseLimitReached, currentAdventureShopPurchaseLimitCode},
{"insufficient points", adventuregroup.ErrInsufficientShopPoints, currentAdventureShopInsufficientCode},
{"inventory full", errDungeonPickupInventoryFull, currentAdventureShopInventoryFullCode},
{"wrapped limit", fmt.Errorf("shop: %w", adventuregroup.ErrPurchaseLimitReached), currentAdventureShopPurchaseLimitCode},
{"unknown stays silent", errCurrentAdventureStateInvalid, currentAdventureGenericFailureCode},
{"plain state invalid stays silent", adventuregroup.ErrRuntimeStateInvalid, currentAdventureGenericFailureCode},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := currentAdventureShopFailureCode(test.err); got != test.want {
t.Fatalf("failure code=%d want=%d", got, test.want)
}
})
}
if currentAdventureShopPurchaseLimitCode != 19 || currentAdventureShopInsufficientCode != 22 ||
currentAdventureShopLevelTooLowCode != 235 || currentAdventureShopInventoryFullCode != 204 {
t.Fatalf("client result codes changed without re-reading 0x00C79940")
}
}
// 商店点数的"进度"字段只由冒险团状态决定:骁勇结晶 = 距下一个结晶的余数,
// 成长胶囊页签 = 当前这一格的进度(累计值 mod 单格容量),其余分类恒 0
// (它们没有经验窗口,绝不能回落成早期那种"分类号当进度"的伪值)。
func TestAdventureShopPointProgressFollowsRuntimeState(t *testing.T) {
projection := adventuregroup.Projection{
Runtime: adventuregroup.RuntimeState{
BraveExperience: 1_912_720,
// 一格 = 116,914,950;这里正好一格 + 半格,进度必须是那"半格"。
GrowthExperience: 175_372_425,
},
CapsuleGauge: 116_914_950,
}
if got := currentAdventureInfoShopPointProgress(int(adventuregroup.ShopPointBrave), projection); got != 1_912_720 {
t.Fatalf("brave progress=%d", got)
}
if got := currentAdventureInfoShopPointProgress(currentAdventureInfoCapsuleProgressEntry, projection); got != 58_457_475 {
t.Fatalf("capsule progress=%d want the current gauge remainder", got)
}
for _, index := range []int{int(adventuregroup.ShopPointGlory), int(adventuregroup.ShopPointPure), 4} {
if got := currentAdventureInfoShopPointProgress(index, projection); got != 0 {
t.Fatalf("progress[%d]=%d want 0", index, got)
}
}
// 正好一格(旧行为下会卡死的那一点):进度必须是 0 而不是 100%,
// 而且再攒一点就应该立刻动起来 —— 现场"胶囊不累计"就是这个位置。
projection.Runtime.GrowthExperience = 116_914_950
if got := currentAdventureInfoShopPointProgress(currentAdventureInfoCapsuleProgressEntry, projection); got != 0 {
t.Fatalf("exactly-one-gauge progress=%d want 0", got)
}
projection.Runtime.GrowthExperience += 1_000_000
if got := currentAdventureInfoShopPointProgress(currentAdventureInfoCapsuleProgressEntry, projection); got != 1_000_000 {
t.Fatalf("progress after one gauge=%d want 1000000", got)
}
// 缺 PVF 分母时不猜:必须回 0。
projection.CapsuleGauge = 0
if got := currentAdventureInfoShopPointProgress(currentAdventureInfoCapsuleProgressEntry, projection); got != 0 {
t.Fatalf("progress without a gauge=%d want 0", got)
}
}