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,527 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"testing"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfmail "longheng.io/server/internal/modules/dnf/mail"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// ---------------------------------------------------------------------------
// Problem 1: the op183 success ack must tell the two panels apart.
//
// Live 2026-09-21T10:24:01Z: an op183 mode-1 consignment was answered `0100`
// (success + the item auction's pay byte), and the client opened the ordinary
// 上架 window instead of finishing the gold one. The item auction's own `0100`
// at 10:24:41 was correct and must stay that way.
// ---------------------------------------------------------------------------
func TestCurrentAuctionRegisterAckKeepsThePanelsApart(t *testing.T) {
// The item auction keeps its own builder and its own pay byte.
if got := buildCurrentAuctionRegisterSuccessBody(101, false); !bytes.Equal(got, []byte{currentAuctionPayTypeGold}) {
t.Fatalf("item op183 ack body=%x want=[00]", got)
}
// The gold panel must hear mode 1, or it opens the wrong window.
if got := buildCurrentGoldConsignRegisterSuccessBody(101, false); !bytes.Equal(got, []byte{currentAuctionPayTypeConsignment}) {
t.Fatalf("gold op183 ack body=%x want=[01]", got)
}
// On the wire sendGameUpperSuccess prepends the success flag, so the two
// panels see 01 00 and 01 01 respectively.
itemWire := upperSuccessBody(buildCurrentAuctionRegisterSuccessBody(101, false))
goldWire := upperSuccessBody(buildCurrentGoldConsignRegisterSuccessBody(101, false))
if !bytes.Equal(itemWire, []byte{1, currentAuctionPayTypeGold}) {
t.Fatalf("item op183 wire=%x want=0100", itemWire)
}
if !bytes.Equal(goldWire, []byte{1, currentAuctionPayTypeConsignment}) {
t.Fatalf("gold op183 wire=%x want=0101", goldWire)
}
// A robot session carries the private trailing listing id in both cases.
itemRobot := buildCurrentAuctionRegisterSuccessBody(101, true)
if len(itemRobot) != 9 || itemRobot[0] != currentAuctionPayTypeGold ||
binary.LittleEndian.Uint64(itemRobot[1:9]) != 101 {
t.Fatalf("item robot ack=%x", itemRobot)
}
goldRobot := buildCurrentGoldConsignRegisterSuccessBody(101, true)
if len(goldRobot) != 9 || goldRobot[0] != currentAuctionPayTypeConsignment ||
binary.LittleEndian.Uint64(goldRobot[1:9]) != 101 {
t.Fatalf("gold robot ack=%x", goldRobot)
}
}
// ---------------------------------------------------------------------------
// Problem 2: a consignment has no bid, so the 竞拍价 line must not render.
//
// The shared painter showed the Cera price in both price slots (an op188 row
// carried `6F000000 6F000000`) because Listing.BidUnitPrice falls back to
// UnitPrice for a buyout-only row.
//
// Zeroing the slot did NOT hide the line - 0 is a real price, so the row simply
// read 「当前竞拍 0 点券」, which is exactly what the operator reported. The gold
// panel's painter (owner 8, FUN_02076540) hides the line only when the slot
// equals 0xFFFFFFFF:
//
// 020768c9 CMP dword ptr [ESI + 0x3d1], -1 ; widget+0x3C8+0x09 = the bid slot
// 020768d0 JZ 0x02076a33 ; -1 => the 当前竞拍 block is skipped
// 0207673e CMP dword ptr [ESI + 0x3d5], EBX(-1) ; +0x0D = the buyout slot
// 02076744 JZ 0x020768c3 ; -1 => that block is skipped
//
// so the row has to carry the sentinel, and nothing else may change.
// ---------------------------------------------------------------------------
func goldConsignFixtureListing() dnfauction.GoldConsignListing {
return dnfauction.GoldConsignListing{
ID: 77,
SellerID: "11",
SellerName: "卖家",
GoldItemID: 2681725,
GoldAmount: 1_000_000,
PriceCera: 111,
FeePercent: 5,
}
}
func TestCurrentGoldConsignRowsMarkTheBidSlotAsNoBid(t *testing.T) {
listings := []dnfauction.GoldConsignListing{goldConsignFixtureListing()}
search := buildCurrentGoldConsignSearchBody(listings, 0, 100)
if len(search) <= currentGoldConsignSearchHeaderSize {
t.Fatalf("search body too short: %x", search)
}
if search[0] != currentAuctionPayTypeConsignment {
t.Fatalf("search mode byte=%d want=1", search[0])
}
row := search[currentGoldConsignSearchHeaderSize:]
if len(row) < currentGoldConsignSearchRowSize {
t.Fatalf("search row short: %d", len(row))
}
bid := binary.LittleEndian.Uint32(row[currentGoldConsignSearchBidOffset : currentGoldConsignSearchBidOffset+4])
buyout := binary.LittleEndian.Uint32(row[0x0D:0x11])
if bid != currentGoldConsignNoBid {
t.Fatalf("op186 gold row 竞拍价=0x%08X want=0x%08X (row=%x)", bid, currentGoldConsignNoBid, row[:0x20])
}
if buyout != 111 {
t.Fatalf("op186 gold row 一口价=%d want=111 - the sentinel must not touch the buyout", buyout)
}
owned := buildCurrentGoldConsignOwnedBody(listings, "11")
if len(owned) <= currentGoldConsignOwnedHeaderSize {
t.Fatalf("owned body too short: %x", owned)
}
if owned[0] != currentAuctionPayTypeConsignment {
t.Fatalf("owned mode byte=%d want=1", owned[0])
}
if owned[1] != 1 {
t.Fatalf("owned count=%d want=1", owned[1])
}
ownedRow := owned[currentGoldConsignOwnedHeaderSize:]
ownedBid := binary.LittleEndian.Uint32(ownedRow[currentGoldConsignOwnedBidOffset : currentGoldConsignOwnedBidOffset+4])
ownedBuyout := binary.LittleEndian.Uint32(ownedRow[0x0C:0x10])
if ownedBid != currentGoldConsignNoBid {
t.Fatalf("op188 gold row 竞拍价=0x%08X want=0x%08X (row=%x)", ownedBid, currentGoldConsignNoBid, ownedRow[:0x20])
}
if ownedBuyout != 111 {
t.Fatalf("op188 gold row 一口价=%d want=111", ownedBuyout)
}
// The unit price the 一口价 column multiplies from must survive too.
if unit := binary.LittleEndian.Uint32(ownedRow[0x93:0x97]); unit != 111 {
t.Fatalf("op188 gold row unit price=%d want=111", unit)
}
}
// The sentinel is the value FUN_02076540 compares against, so it must be the
// exact all-ones dword - not 0, not -1 as a signed 64-bit value, not a flag.
func TestCurrentGoldConsignNoBidSentinelMatchesThePainterCompare(t *testing.T) {
if currentGoldConsignNoBid != 0xFFFFFFFF {
t.Fatalf("no-bid sentinel=0x%08X want=0xFFFFFFFF", currentGoldConsignNoBid)
}
// A zero slot is a real price and is exactly what the rejected first attempt
// wrote; pin that the two can never be confused again.
if currentGoldConsignNoBid == 0 {
t.Fatal("the no-bid sentinel must not be zero")
}
}
// markCurrentGoldConsignNoBid must never walk off the end of a body, including
// the empty and header-only shapes the client accepts.
func TestCurrentGoldConsignNoBidMarkingIsBoundsSafe(t *testing.T) {
for _, test := range []struct {
name string
body []byte
header, row, off int
}{
{"nil", nil, 7, 0xA8, 0x09},
{"empty", []byte{}, 7, 0xA8, 0x09},
{"header only", make([]byte, 7), 7, 0xA8, 0x09},
{"zero row size", make([]byte, 64), 7, 0, 0x09},
{"single byte", []byte{1}, 7, 0xA8, 0x09},
} {
t.Run(test.name, func(t *testing.T) {
markCurrentGoldConsignNoBid(test.body, test.header, test.row, test.off)
})
}
// One full row plus a partial second must rewrite exactly the first.
body := make([]byte, 7+0xA8+0x20)
for i := range body {
body[i] = 0x00
}
markCurrentGoldConsignNoBid(body, 7, 0xA8, 0x09)
if got := binary.LittleEndian.Uint32(body[7+0x09 : 7+0x0D]); got != currentGoldConsignNoBid {
t.Fatalf("first row bid=0x%08X want the sentinel", got)
}
// The bytes around the four-byte slot must be untouched.
if body[7+0x08] != 0 || body[7+0x0D] != 0 {
t.Fatalf("sentinel write leaked outside the slot: %x", body[7+0x08:7+0x0E])
}
if body[7+0xA8+0x09] != 0 {
t.Fatal("marking reached into an incomplete second row")
}
}
// ---------------------------------------------------------------------------
// Problems 4 and 5: the system notice.
//
// FUN_01d80150 is registered as class-0 msg 0xA9 by
// FUN_02262980(0xa9, FUN_01d80150, 0). It reads two bytes through
// FUN_034e3970(x,1), a u32 through FUN_034e3a50 (cursor -= 4) and a u16 through
// FUN_034e39e0 (cursor -= 2) - eight bytes, with no trailing block, because
// FUN_02fd3b90 is a panel factory (owner 7 = 0x824 bytes, owner 8 = 0x250
// bytes), not a cursor advance.
// ---------------------------------------------------------------------------
func TestCurrentAuctionNoticeBodyMatchesTheClientReader(t *testing.T) {
body := buildCurrentAuctionNoticeBody(
currentAuctionNoticeRouteGold,
currentAuctionNoticeKindSellerSold,
2681725,
)
// route u8 | kind u8 | itemID u32 | count u16
want := []byte{0x01, 0x01, 0x7D, 0xEB, 0x28, 0x00, 0x00, 0x00}
if !bytes.Equal(body, want) {
t.Fatalf("notice body=%x want=%x", body, want)
}
if len(body) != 8 {
t.Fatalf("notice body len=%d want=8", len(body))
}
}
// The route byte is what sends the banner to owner 7 (item auction) or owner 8
// (金币寄售). Every kind in FUN_01d80150's switch must round-trip.
func TestCurrentAuctionNoticeKindTableMatchesTheClientSwitch(t *testing.T) {
for kind, wantItem := range map[byte]bool{
currentAuctionNoticeKindOutbid: true,
currentAuctionNoticeKindSellerSold: true,
currentAuctionNoticeKindBuyerWon: true,
currentAuctionNoticeKindSellerFailed: true,
currentAuctionNoticeKindCancelRefund: true,
currentAuctionNoticeKindCancelBid: true,
} {
_ = wantItem
body := buildCurrentAuctionNoticeBody(currentAuctionNoticeRouteItem, kind, 6001)
if body[0] != currentAuctionNoticeRouteItem || body[1] != kind {
t.Fatalf("kind %d round trip=%x", kind, body)
}
}
if currentAuctionNoticeMsgID != 0x00A9 {
t.Fatalf("notice msg id=0x%04X want=0x00A9", currentAuctionNoticeMsgID)
}
}
func TestCurrentAuctionNoticeLandsAsClass0OpA9(t *testing.T) {
connection := &bufferConn{}
recipient := &gameSession{conn: connection, connID: "notice-recipient", selectedCharacterID: 88}
service := &Service{
options: options{
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
gameSessions: map[uint16]*gameSession{88: recipient},
}
if err := service.sendCurrentAuctionNoticeToOnlineRecipient(
88, currentAuctionNoticeRouteGold, currentAuctionNoticeKindBuyerWon, 2681725); err != nil {
t.Fatalf("send notice: %v", err)
}
// The service above runs the 16-byte class-0 header, so the matching splitter
// is the long-Heng one; with the 13-byte splitter the body is mis-sliced by
// the three header bytes the packet carries before the payload.
packet, trailing := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Header.MsgID != currentAuctionNoticeMsgID || packet.Header.Classification != 0 {
t.Fatalf("notice header=%+v want class0/0x00A9", packet.Header)
}
want := []byte{currentAuctionNoticeRouteGold, currentAuctionNoticeKindBuyerWon, 0x7D, 0xEB, 0x28, 0x00, 0x00, 0x00}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("notice wire body=%x want=%x", packet.Body, want)
}
if len(packet.Body) != 8 {
t.Fatalf("notice wire body len=%d want=8 (no trailing block)", len(packet.Body))
}
// An offline recipient is a silent no-op, not an error.
connection.write.Reset()
if err := service.sendCurrentAuctionNoticeToOnlineRecipient(99, currentAuctionNoticeRouteGold, 1, 2681725); err != nil {
t.Fatalf("offline notice: %v", err)
}
if connection.write.Len() != 0 {
t.Fatal("offline notice wrote to the wire")
}
// A zero or out of range item id never invents a banner.
if err := service.sendCurrentAuctionNoticeToOnlineRecipient(88, 0, 0, 0); err != nil {
t.Fatalf("zero item notice: %v", err)
}
if connection.write.Len() != 0 {
t.Fatal("notice with no item id wrote to the wire")
}
}
// ---------------------------------------------------------------------------
// op185: the captured body is 17 bytes and ends with the tier the panel showed.
//
// 10:25:15 01 6F000000 6103B16A00000000 7DEB2800
// mode i32 total i64 listingId u32 tier(2681725)
// ---------------------------------------------------------------------------
func TestCurrentGoldConsignBuyDecoderReadsTheCapturedBody(t *testing.T) {
// Exactly the bytes from packet_log.txt at 2026-09-21T10:25:15Z.
captured := []byte{
0x01,
0x6F, 0x00, 0x00, 0x00,
0x61, 0x03, 0xB1, 0x6A, 0x00, 0x00, 0x00, 0x00,
0x7D, 0xEB, 0x28, 0x00,
}
if len(captured) != currentAuctionGoldBuyRequestSizeWithTrailer {
t.Fatalf("fixture len=%d want=%d", len(captured), currentAuctionGoldBuyRequestSizeWithTrailer)
}
request, err := decodeCurrentGoldConsignBuyRequest(captured)
if err != nil {
t.Fatalf("decode captured: %v", err)
}
if request.ExpectedCera != 111 {
t.Fatalf("expected cera=%d want=111", request.ExpectedCera)
}
if request.ListingID != 0x6AB10361 {
t.Fatalf("listing id=0x%X want=0x6AB10361", request.ListingID)
}
if !request.HasGoldItemID || request.GoldItemID != 2681725 {
t.Fatalf("tier=%d present=%t want 2681725 present", request.GoldItemID, request.HasGoldItemID)
}
// The pre-capture 13-byte shape stays accepted and simply carries no tier.
short := captured[:currentAuctionGoldBuyRequestSize]
shortRequest, err := decodeCurrentGoldConsignBuyRequest(short)
if err != nil {
t.Fatalf("decode 13-byte: %v", err)
}
if shortRequest.HasGoldItemID {
t.Fatal("13-byte body must not claim a tier")
}
if shortRequest.ExpectedCera != 111 || shortRequest.ListingID != 0x6AB10361 {
t.Fatalf("13-byte decode=%+v", shortRequest)
}
for _, test := range []struct {
name string
body []byte
}{
{"nil", nil},
{"empty", []byte{}},
{"mode 0", append([]byte{0x00}, captured[1:]...)},
{"truncated to 12", captured[:12]},
{"one byte too long", append(append([]byte(nil), captured...), 0x00)},
{"zero price", append([]byte{0x01, 0, 0, 0, 0}, captured[5:]...)},
{"zero listing", append(append([]byte(nil), captured[:5]...), make([]byte, 12)...)},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := decodeCurrentGoldConsignBuyRequest(test.body); err == nil {
t.Fatalf("accepted %x", test.body)
}
})
}
// A zero tier is a malformed trailer rather than an absent one.
zeroTier := append([]byte(nil), captured...)
for i := 13; i < 17; i++ {
zeroTier[i] = 0
}
if _, err := decodeCurrentGoldConsignBuyRequest(zeroTier); err == nil {
t.Fatal("accepted a zero tier trailer")
}
}
// ---------------------------------------------------------------------------
// Problem 3: a gold letter must raise the live mailbox indicator.
//
// The item auction fires class0/op0x63 next to its own mail sends; without it a
// consignment letter stayed invisible until the player opened the mailbox.
// ---------------------------------------------------------------------------
func TestCurrentGoldConsignLetterFiresTheMailboxAlarm(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "88",
AccountID: "acct",
Name: "收件人",
}); err != nil {
t.Fatalf("save recipient: %v", err)
}
connection := &bufferConn{}
recipient := &gameSession{conn: connection, connID: "gold-letter-recipient", selectedCharacterID: 88}
service := &Service{
options: options{
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{88: recipient},
}
if err := service.sendCurrentGoldConsignLetter(ctx, dnfmail.SystemSendRequest{
RecipientCharacterID: "88",
Title: "金币寄售成交",
Body: "测试",
Source: "goldconsign_test",
CompletionKey: "goldconsign:test:1",
}); err != nil {
t.Fatalf("send gold letter: %v", err)
}
// The alarm body is the unread count as one u16; the 16-byte class-0 header
// leaves exactly those two bytes behind the splitter.
alarm, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if alarm.Header.MsgID != 0x0063 || alarm.Header.Classification != 0 {
t.Fatalf("alarm header=%+v want class0/0x0063", alarm.Header)
}
// One unread letter, so the count word is 1.
if !bytes.Equal(alarm.Body, []byte{0x01, 0x00}) {
t.Fatalf("alarm body=% X want 01 00", alarm.Body)
}
// Immediately behind it comes the mailbox list page. Without that page the
// client only bumps a counter, and an already-open mailbox never reloads.
page, trailing := splitLongHengGameServerUpperPacket(t, rest)
if len(trailing) != 0 {
t.Fatalf("gold letter left trailing bytes on the wire: %x", trailing)
}
if page.Header.MsgID != 0x0061 || page.Header.Classification != 0 {
t.Fatalf("mailbox page header=%+v want class0/0x0061", page.Header)
}
// One summary row, and the second byte is the clear-first flag the client's
// sub_1D913C0 uses to reset both mail containers before the snapshot.
if len(page.Body) < 2 || page.Body[0] != 1 || page.Body[1] != 0 {
t.Fatalf("mailbox page body=% X want one summary and a zero clear flag", page.Body)
}
}
// The gold refresh is a gold-only projection: the shared alarm helper used by
// every other mail path must still put exactly one packet on the wire, so the
// item auction's delivery stays byte-identical.
func TestCurrentGoldConsignMailboxRefreshStaysOutOfOtherPaths(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "acct",
Name: "其他收件人",
}); err != nil {
t.Fatalf("save recipient: %v", err)
}
connection := &bufferConn{}
recipient := &gameSession{conn: connection, connID: "plain-recipient", selectedCharacterID: 99}
service := &Service{
options: options{
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{99: recipient},
}
if err := service.sendMailboxAlarmToOnlineRecipient(99); err != nil {
t.Fatalf("alarm: %v", err)
}
packet, trailing := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("the shared alarm helper grew a second packet: %x", trailing)
}
if packet.Header.MsgID != 0x0063 {
t.Fatalf("shared alarm msg id=0x%04X want 0x0063", packet.Header.MsgID)
}
// An offline or unknown recipient is a silent no-op for the page too.
connection.write.Reset()
if err := service.sendMailboxListRefreshToOnlineRecipient(0); err != nil {
t.Fatalf("zero id: %v", err)
}
if err := service.sendMailboxListRefreshToOnlineRecipient(77); err != nil {
t.Fatalf("offline recipient: %v", err)
}
if connection.write.Len() != 0 {
t.Fatal("an offline recipient was written to")
}
}
// ---------------------------------------------------------------------------
// op185's trailing tier must agree with the row its listing id resolves to, or
// the buyer is confirming a panel that has since changed denomination.
// ---------------------------------------------------------------------------
func TestCurrentGoldConsignBuyRefusesAStaleTierPanel(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignProtocolCharacter(t, ctx, repositories, "11", "account-seller", "Seller", 40, 5_000_000, nil)
seedGoldConsignProtocolCharacter(t, ctx, repositories, "22", "account-buyer", "Buyer", 20, 0,
map[string]string{"account_cera": "5000"})
// goldConsignTestService runs the 13-byte class-0 header, so the default
// splitter is the matching one here.
service := goldConsignTestService(repositories)
seller := &gameSession{conn: &bufferConn{}, selectedCharacterID: 11, accountID: "account-seller"}
// goldConsignLiveRegisterBody registers tier 2681725 (1百万) at 111 Cera.
if err := service.registerCurrentAuctionItemLocked(seller, goldConsignLiveRegisterBody()); err != nil {
t.Fatal(err)
}
listingID := service.nextAuctionListingID.Load()
buyerConn := &bufferConn{}
buyer := &gameSession{conn: buyerConn, selectedCharacterID: 22, accountID: "account-buyer"}
// The captured 17-byte shape, but naming a different tier than the row.
buy := make([]byte, currentAuctionGoldBuyRequestSizeWithTrailer)
buy[0] = currentAuctionPayTypeConsignment
binary.LittleEndian.PutUint32(buy[1:5], 111)
binary.LittleEndian.PutUint64(buy[5:13], listingID)
binary.LittleEndian.PutUint32(buy[13:17], 2681726)
if err := service.buyCurrentAuctionItemLocked(buyer, uint16(dnfenum.CmdPacketAuctionBidding), buy, false); err != nil {
t.Fatal(err)
}
ack, _ := splitGameServerUpperPacket(t, buyerConn.write.Bytes())
if ack.Body[0] != 0 {
t.Fatalf("stale-tier buy was accepted: ack=%x", ack.Body)
}
// The reservation has to be released: the gold is still escrowed and the row
// stays buyable at its real tier.
if gold := goldConsignProtocolGold(t, ctx, repositories, "system:auction:escrow"); gold != 1_000_000 {
t.Fatalf("escrow after stale-tier refusal = %d want 1000000 (reservation leaked)", gold)
}
if _, found, err := repositories.Mailbox.Load(ctx, "22"); err != nil {
t.Fatal(err)
} else if found {
t.Fatal("a refused buy wrote a letter")
}
// The same body carrying the tier the row actually holds still succeeds.
goodConn := &bufferConn{}
goodBuyer := &gameSession{conn: goodConn, selectedCharacterID: 22, accountID: "account-buyer"}
good := append([]byte(nil), buy...)
binary.LittleEndian.PutUint32(good[13:17], 2681725)
if err := service.buyCurrentAuctionItemLocked(goodBuyer, uint16(dnfenum.CmdPacketAuctionBidding), good, false); err != nil {
t.Fatal(err)
}
goodAck, _ := splitGameServerUpperPacket(t, goodConn.write.Bytes())
if goodAck.Body[0] != 1 {
t.Fatalf("matching-tier buy was refused: ack=%x", goodAck.Body)
}
}