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

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

239 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}