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,238 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// 2026-09-21 报障:一件「远古遗愿」(28309) 在拍卖行和摆摊都上不了架。
|
||||
//
|
||||
// 数据库里的真实那一行(dnf_local, dnf_inventory_items 角色 1 槽位 0:11):
|
||||
//
|
||||
// item_id=28309 count=1 bind_flag=0 raw_len=119 raw_entry[0x0D]=0x01
|
||||
// dnf_inventory_item_extra: seal_flag=1, quality_seed=589339874
|
||||
// last_grant_source=gm_admin_system_mail
|
||||
//
|
||||
// 也就是说它是**已封装**的(raw_entry[0x0D]=1 且 seal_flag=1),只是 bind_flag 为 0。
|
||||
// PVF 里 28309 是 `[attach type] `[sealing]“ + `[rarity] 3`。
|
||||
//
|
||||
// 旧代码在三个地方各拦一次,所以修好任何一个都还是上不了架:
|
||||
// 1. currentDungeonDiscardTradeable 白名单只有 free/trade,没有 sealing;
|
||||
// 2. 两条准入策略都用 stack.Bind 当封装位,封装但 bind_flag=0 被判成「解封」;
|
||||
// 3. 品级白名单只有 {0,1} 与 {2,6},rarity 3 落到 fail-closed 的 default。
|
||||
//
|
||||
// 这条用例把「sealing + rarity 3 + seal_flag=1 + bind_flag=0」这个组合钉住。
|
||||
func TestSealingAttachRarityThreeSealedEquipmentIsListable(t *testing.T) {
|
||||
definition := dungeonDropItemDefinition{
|
||||
ItemID: 28309, Kind: dungeonDropItemEquipment, AttachType: "[sealing]", Rarity: 3,
|
||||
}
|
||||
stack := currentAuctionRealPVFSealedBeamswordStack()
|
||||
|
||||
if !currentDungeonDiscardTradeable(definition) {
|
||||
t.Fatal("[sealing] 必须在交易白名单里")
|
||||
}
|
||||
if !currentDungeonDiscardSealRequired(definition) {
|
||||
t.Fatal("[sealing] 必须要求封装态")
|
||||
}
|
||||
if !currentDungeonDiscardSealed(stack) {
|
||||
t.Fatal("seal_flag=1 / raw_entry[0x0D]=1 必须读成已封装(即使 bind_flag=0)")
|
||||
}
|
||||
|
||||
// 上架策略冻结的是 escrow owner 回查的 stack.Bind,这里 bind_flag=0,
|
||||
// 所以两个返回值都必须是 false —— 回传封装位会让托管那一步打回。
|
||||
expected, allow, eligible := currentAuctionRegistrationPolicyForDefinition(definition, stack)
|
||||
if !eligible || expected || allow {
|
||||
t.Fatalf("拍卖策略=(expected=%t allow=%t eligible=%t), 期望 (false,false,true)", expected, allow, eligible)
|
||||
}
|
||||
expected, allow, eligible = currentPrivateStoreRegistrationPolicyForDefinition(definition, stack)
|
||||
if !eligible || expected || allow {
|
||||
t.Fatalf("摆摊策略=(expected=%t allow=%t eligible=%t), 期望 (false,false,true)", expected, allow, eligible)
|
||||
}
|
||||
|
||||
// 解封件仍然不放行:raw_entry[0x0D]=0 且没有 seal extra。
|
||||
unsealed := stack
|
||||
unsealed.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||||
unsealed.RawEntry[0x0D] = 0
|
||||
unsealed.Extra = map[string]string{"item_kind": "equipment", "quality_seed": "589339874"}
|
||||
if currentDungeonDiscardSealed(unsealed) {
|
||||
t.Fatal("raw_entry[0x0D]=0 且无 seal extra 必须读成解封")
|
||||
}
|
||||
if _, _, eligible := currentAuctionRegistrationPolicyForDefinition(definition, unsealed); eligible {
|
||||
t.Fatal("解封的 [sealing] 装备不允许上架")
|
||||
}
|
||||
if _, _, eligible := currentPrivateStoreRegistrationPolicyForDefinition(definition, unsealed); eligible {
|
||||
t.Fatal("解封的 [sealing] 装备不允许摆摊")
|
||||
}
|
||||
}
|
||||
|
||||
// currentAuctionRealPVFSealedBeamswordStack 复刻数据库里 28309 那一行的原始
|
||||
// raw_entry(119 字节,0x77 线上行),避免手搓字段和线上不一致。
|
||||
func currentAuctionRealPVFSealedBeamswordStack() dnfrepo.ItemStack {
|
||||
const rawHex = "0900956E0000E29C202300300001000000000000000000000000000000000000" +
|
||||
"0000000000000000000000000000000000000000000000000000000000000000" +
|
||||
"0000000000000000000000000000000000000000000000000000000000000000" +
|
||||
"0000000000000000000000000000000000000000000000"
|
||||
raw, err := hex.DecodeString(rawHex)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(raw) != currentItemListEntryWireSize || raw[0x0D] != 1 {
|
||||
panic("28309 raw_entry 必须是 119 字节且 +0x0D=1")
|
||||
}
|
||||
return dnfrepo.ItemStack{
|
||||
ItemID: 28309,
|
||||
Count: 1,
|
||||
Bind: false, // 线上 bind_flag=0
|
||||
RawEntry: raw,
|
||||
Extra: map[string]string{
|
||||
"item_kind": "equipment",
|
||||
"seal_flag": "1",
|
||||
"seal": "1",
|
||||
"quality_seed": "589339874",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 拍卖行端到端:用真机 PVF 解析 28309,走完整的 op183 上架(含 escrow 托管)。
|
||||
// 这条覆盖的是「策略放行 + owner 事务回查绑定位」两段,单测策略通过但托管
|
||||
// 打回的情况不会漏网。
|
||||
func TestCurrentAuctionRealPVFRegistersSealedSealingEquipment(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to register sealed [sealing] equipment")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := newPVFDungeonDropCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := catalog.ResolveItem(28309)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if definition.Kind != dungeonDropItemEquipment || definition.Rarity != 3 ||
|
||||
normalizeMagicBoxPVFType(definition.AttachType) != "sealing" {
|
||||
t.Fatalf("28309 定义=%+v,期望 equipment/rarity 3/[sealing]", definition)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
stack := currentAuctionRealPVFSealedBeamswordStack()
|
||||
seedCurrentAuctionBridgeCharacter(t, ctx, repositories, "1", "dnf:1", "Seller", 10_000_000,
|
||||
map[string]dnfrepo.ItemStack{"0:11": stack})
|
||||
|
||||
connection := &bufferConn{}
|
||||
service := &Service{
|
||||
options: options{gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
pvfItemCatalog: catalog,
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
}
|
||||
service.nextAuctionListingID.Store(2000)
|
||||
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
|
||||
// buildCurrentAuctionRegisterRequestForTest(slot, itemID, count, startPrice, instantPrice, unitPrice, durationHours)
|
||||
body := buildCurrentAuctionRegisterRequestForTest(11, 28309, 1, -1, 6000, 6000, 24)
|
||||
if err := service.registerCurrentAuctionItemLocked(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ack, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
|
||||
if ack.Header.MsgID != uint16(183) || len(ack.Body) != 2 || ack.Body[0] != 1 {
|
||||
t.Fatalf("上架 ACK=%x(body[1] 是失败码,145 就是旧行为的「无法上架到拍卖行的物品」)", ack.Body)
|
||||
}
|
||||
// 成功上架后服务端还会接着推自己的在售列表刷新,所以这里不校验 trailing。
|
||||
if ack.Body[1] != 0 {
|
||||
t.Fatalf("上架 pay/失败码=%d,期望 0", ack.Body[1])
|
||||
}
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, "1")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("读回背包 found=%t err=%v", found, err)
|
||||
}
|
||||
if remaining := inventory.Slots["0:11"]; remaining.ItemID != 0 || remaining.Count != 0 {
|
||||
t.Fatalf("上架成功后源格必须清空,实际=%+v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// 摆摊端到端:op87 用的是 2026-09-21 真机失败时抓到的**原始包体**
|
||||
// (packet_log 里 body_hex=06000000B0A1B0A1B0A101000007B20100000B00E29C2023FFFF,
|
||||
// 当时服务端回 row_source_stack_unusable)。现在同样的包体必须开摊成功。
|
||||
func TestCurrentPrivateStoreRealPVFAcceptsSealedSealingEquipment(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to complete the captured sealed [sealing] stall body")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := newPVFDungeonDropCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "dnf:1",
|
||||
Metadata: map[string]string{
|
||||
premium.MetadataKey(premium.TypePrivateStore): strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10),
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "1", AccountID: "dnf:1", Name: "Seller", Stats: map[string]int64{"gold": 10_000_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "1",
|
||||
Slots: map[string]dnfrepo.ItemStack{"0:11": currentAuctionRealPVFSealedBeamswordStack()},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
connection := &bufferConn{}
|
||||
session := &gameSession{conn: connection, selectedCharacterID: 1, accountID: "dnf:1"}
|
||||
service := &Service{
|
||||
options: options{gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
||||
pvfItemCatalog: catalog,
|
||||
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
||||
gameSessions: map[uint16]*gameSession{1: session},
|
||||
privateStores: map[uint16]*currentPrivateStore{1: {OwnerID: 1, OwnerSession: session}},
|
||||
privateStoreVisitors: make(map[*gameSession]uint16),
|
||||
onlinePlayers: newOnlinePlayerManager(),
|
||||
}
|
||||
// 真机抓到的原始 op87 包体:标题 6 字节、1 行、格子 0:11、一口价 111111、
|
||||
// 实例 589339874、权限位 0xFFFF(没有摆摊人偶)。
|
||||
body, err := hex.DecodeString("06000000B0A1B0A1B0A101000007B20100000B00E29C2023FFFF")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.completeCurrentPrivateStore(session, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := service.privateStores[1]
|
||||
if store == nil || !store.Active || len(store.Rows) != 1 {
|
||||
t.Fatalf("摆摊未开成功:%+v", store)
|
||||
}
|
||||
row := store.Rows[0]
|
||||
if row.ItemID != 28309 || row.Count != 1 || row.InstanceValue != 589339874 ||
|
||||
row.Price != 111111 || row.Slot != 11 {
|
||||
t.Fatalf("摆摊行=%+v,期望 item=28309 count=1 instance=589339874 price=111111 slot=11", row)
|
||||
}
|
||||
// escrow owner 回查的必须是 bind_flag 本身:这一行 bind_flag=0。
|
||||
if row.ExpectedBind {
|
||||
t.Fatalf("ExpectedBind 必须冻结 bind_flag=false,实际=%t(回传封装位会让交接被打回)", row.ExpectedBind)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user