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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user