Files
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

153 lines
6.5 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"math"
"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"
)
// The current client replays the dword it was shown at +0x06 of the item row,
// so the private-store expectation has to be whatever the list projection puts
// there. A legacy 43-byte row carries a different field at that offset: the old
// RawEntry[6:10] read answered 0 for it while the client returned the projected
// count, and every stall containing such an item failed op87 with error 4.
func TestPrivateStoreEquipmentInstanceMatchesProjectedRow(t *testing.T) {
seeded := dnfrepo.ItemStack{
ItemID: 5001,
Count: 1,
Extra: map[string]string{"item_kind": "equipment"},
RawEntry: func() []byte {
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint32(raw[2:6], 5001)
binary.LittleEndian.PutUint32(raw[6:10], 0x0A48BC23)
return raw
}(),
}
// The live 2026-09-11 row: equipment imported with the 43-byte legacy
// projection and no quality seed at all.
legacy := dnfrepo.ItemStack{
ItemID: 101010912,
Count: 1,
Extra: map[string]string{"item_kind": "equipment"},
RawEntry: []byte{0x0b, 0xe0, 0x4d, 0x05, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00},
}
bare := dnfrepo.ItemStack{ItemID: 101010912, Count: 1, Extra: map[string]string{"item_kind": "equipment"}}
for name, stack := range map[string]dnfrepo.ItemStack{"seeded": seeded, "legacy": legacy, "bare": bare} {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, 26, stack)
projected := binary.LittleEndian.Uint32(entry.data[0x06:0x0A])
if got := currentPrivateStoreEquipmentInstance(dnfrepo.MainInventoryListType, stack); got != projected {
t.Fatalf("%s: private-store instance %d disagrees with projected row dword %d", name, got, projected)
}
if projected == 0 {
t.Fatalf("%s: projected instance dword is 0; the row would be unsellable", name)
}
}
if projectedLegacy := currentPrivateStoreEquipmentInstance(dnfrepo.MainInventoryListType, legacy); projectedLegacy != 1 {
t.Fatalf("legacy row instance = %d, want the projected count 1", projectedLegacy)
}
if seededInstance := currentPrivateStoreEquipmentInstance(dnfrepo.MainInventoryListType, seeded); seededInstance != 0x0A48BC23 {
t.Fatalf("seeded row instance = %#x, want the quality seed 0x0A48BC23", seededInstance)
}
}
// A crystal listed straight out of the account warehouse: the client names that
// container list 36 while keeping the merged list-0 slot, and the row has to
// resolve against the account record rather than the character inventory.
func TestPrivateStoreCompleteAcceptsAccountWarehouseRow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
const accountID = "account-crystal-store"
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: accountID,
Metadata: map[string]string{
premium.MetadataKey(currentPrivateStorePremiumType): strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10),
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: accountID,
Slots: map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 3037, Count: 1066, Extra: map[string]string{"item_kind": "stackable"}},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "10", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "10", AccountID: accountID, Name: "Seller"}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
session := &gameSession{conn: connection, selectedCharacterID: 10, accountID: accountID}
service := &Service{
options: options{gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
privateStoreVisitors: make(map[*gameSession]uint16),
privateStoreItemEligibility: func(itemID uint32) bool { return itemID == 3037 },
onlinePlayers: newOnlinePlayerManager(),
privateStores: map[uint16]*currentPrivateStore{
10: {OwnerID: 10, OwnerAccountID: accountID, OwnerSession: session},
},
}
// Exact live row shape: grid cell 2, price 100, list 36, slot 358, 200 units.
var complete packetWriter
complete.writeUint32(3)
complete.writeBytes([]byte("123"))
complete.writeByte(1)
complete.writeUint16(2)
complete.writeUint32(100)
complete.writeByte(currentPrivateStoreAccountSharedListType)
complete.writeUint16(358)
complete.writeUint32(200)
complete.writeUint16(math.MaxUint16)
if err := service.completeCurrentPrivateStore(session, complete.bytes()); err != nil {
t.Fatal(err)
}
if connection.write.Len() == 0 {
t.Fatal("op87 completion produced no response at all")
}
stored := service.privateStores[10]
if stored == nil || !stored.Active || len(stored.Rows) != 1 {
t.Fatalf("store = %+v", stored)
}
row := stored.Rows[0]
if row.ListType != dnfrepo.MainInventoryListType || row.Slot != 358 || row.ItemID != 3037 || row.Count != 200 || row.Price != 100 {
t.Fatalf("frozen row = %+v", row)
}
}
// Both private-store purchase notices carry the quantity that was just sold,
// because the client subtracts that dword from the cell it already tracks
// (store window vtable+0x7C). Sending the remainder there made the seller's own
// stall drift to 1 after the first sale and then close itself on the next one.
func TestPrivateStorePurchaseNoticesCarrySoldQuantity(t *testing.T) {
const displayIndex, soldQuantity = uint16(11), uint32(192)
count := buildCurrentPrivateStoreCountNotification(displayIndex, soldQuantity)
if len(count) != 6 {
t.Fatalf("count notification length = %d, want 6", len(count))
}
if binary.LittleEndian.Uint16(count[0:2]) != displayIndex ||
binary.LittleEndian.Uint32(count[2:6]) != soldQuantity {
t.Fatalf("count notification = %x", count)
}
notice := buildCurrentPrivateStorePurchaseNotification(3037, displayIndex, soldQuantity, "奶爸", 100, soldQuantity, 971834073)
// u8 mode, u32 seller gold, u32 item, u16 cell, u32 sold, dstr name, ...
if binary.LittleEndian.Uint16(notice[9:11]) != displayIndex ||
binary.LittleEndian.Uint32(notice[11:15]) != soldQuantity {
t.Fatalf("seller notification = %x", notice)
}
}