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,642 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"strconv"
"testing"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// goldConsignTestService is the minimum a gold handler needs now that the
// ledger answers from real rows instead of a hardcoded empty list: a repository
// group, so EnsureEscrow can create the shared escrow character.
func goldConsignTestService(repositories dnfrepo.Group) *Service {
return &Service{
options: options{
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
}
// Gold consignment (金币寄售) shares op182 with the ordinary item auction but is
// a distinct request shape. The bodies below are not invented: they are the
// exact bytes the live client sent on 2026-09-21T09:22Z, read out of
// runtime/logs/packet_log.txt, where every one of them was answered with
// body 00 99 00 - failure code 153 「因不明原因操作失败」, the popup the gold
// panel showed the moment it opened.
//
// 09:22:21 body=017DEB280000 mode 1 + item 2681725 (gold_1m, 「1百万」)
// 09:22:29 body=017FEB280000 mode 1 + item 2681727 (gold_3m, 「3百万」)
// 09:22:31 body=017DEB280000 mode 1 + item 2681725
// 09:22:33 body=0185EB280000 mode 1 + item 2681733 (gold_9m, 「9百万」)
//
// All three item ids fall inside the 17-entry denomination table the client
// hardcodes at NoPack.exe .rdata VA 0x4580AA8 (2681725..2681736 then
// 2683069..2683073), which is how the capture and the client table confirm each
// other.
func TestCurrentAuctionGoldAveragePriceAcceptsLiveCaptureShape(t *testing.T) {
for _, live := range []struct {
name string
body []byte
itemID uint32
}{
{"1百万 gold_1m", []byte{0x01, 0x7D, 0xEB, 0x28, 0x00, 0x00}, 2681725},
{"3百万 gold_3m", []byte{0x01, 0x7F, 0xEB, 0x28, 0x00, 0x00}, 2681727},
{"9百万 gold_9m", []byte{0x01, 0x85, 0xEB, 0x28, 0x00, 0x00}, 2681733},
} {
t.Run(live.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
}}
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.sendCurrentAuctionAveragePriceLocked(session, live.body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketAuctionAskAveragePrice) {
t.Fatalf("msgID=%d want=%d", packet.Header.MsgID, dnfenum.CmdPacketAuctionAskAveragePrice)
}
// Success flag, then the mode byte echoed back as 1, then the same
// 57-byte envelope the item auction reader consumes.
if len(packet.Body) != 58 || packet.Body[0] != 1 {
t.Fatalf("body=%x len=%d", packet.Body, len(packet.Body))
}
if packet.Body[1] != currentAuctionPayTypeConsignment {
t.Fatalf("mode byte=%d want=1 body=%x", packet.Body[1], packet.Body)
}
// No consignment ledger exists, so the honest answer is zero average
// and zero sales count - every remaining word must be zero.
if !bytes.Equal(packet.Body[2:], make([]byte, 56)) {
t.Fatalf("non-zero payload for a ledger that has no sales: %x", packet.Body)
}
if got := binary.LittleEndian.Uint32(packet.Body[2:6]); got != 0 {
t.Fatalf("average=%d want=0", got)
}
if got := binary.LittleEndian.Uint32(packet.Body[6:10]); got != 0 {
t.Fatalf("count=%d want=0", got)
}
if live.itemID == 0 {
t.Fatal("fixture lost its item id")
}
})
}
}
// The gold branch may only ever claim the one shape the gold panel actually
// sends. Every neighbouring shape must keep the refusal it had before, so a
// future client change cannot silently widen what this server accepts.
func TestCurrentAuctionAveragePriceGateOnlyWidensForLiveGoldShape(t *testing.T) {
gold := func(mode byte, itemID uint32, reserved byte) []byte {
body := []byte{mode, 0, 0, 0, 0, reserved}
binary.LittleEndian.PutUint32(body[1:5], itemID)
return body
}
item := func(mode byte, itemID uint32) []byte {
body := make([]byte, 8)
body[0] = mode
binary.LittleEndian.PutUint32(body[1:5], itemID)
return body
}
for _, test := range []struct {
name string
body []byte
}{
{"nil body", nil},
{"empty body", []byte{}},
{"6 bytes mode 0 is not consignment", gold(0, 2681725, 0)},
{"6 bytes mode 2 is not consignment", gold(2, 2681725, 0)},
{"6 bytes mode 1 item 0 is refused", gold(1, 0, 0)},
{"5 bytes mode 1 truncated", gold(1, 2681725, 0)[:5]},
{"7 bytes mode 1 overlong", append(gold(1, 2681725, 0), 0)},
{"8 bytes mode 1 stays on the item gate", item(1, 6001)},
{"8 bytes mode 0 without a repository still refuses", item(0, 6001)},
} {
t.Run(test.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
}}
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.sendCurrentAuctionAveragePriceLocked(session, test.body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
// The refusal the gold panel used to get, unchanged: failure flag,
// code 153, then the pay-feature byte op182 requires.
want := []byte{0, currentAuctionFailureCode, currentAuctionPayTypeGold}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("body=%x want=%x", packet.Body, want)
}
})
}
}
// The 金币寄售 "金币搜索" request is op186 with mode 1 and no 8-byte detail tail.
// This is the exact body the live client sent for the 1百万 tree node on
// 2026-09-21T09:35:47Z (packet_log.txt), which the server answered 00 99 00 -
// failure code 153 「因不明原因操作失败」, the second popup.
//
// body = 01 00000000 00 1F 01 A59C 7DEB2800
// mode page - - n cat(=40101) item(=2681725)
func TestCurrentAuctionGoldSearchAcceptsLiveCaptureShape(t *testing.T) {
live := []byte{
0x01, // mode 1 = 金币寄售
0x00, 0x00, 0x00, 0x00, // page 0
0x00, 0x1F, // the two bytes the item panel also sends
0x01, // count 1
0xA5, 0x9C, // category 40101
0x7D, 0xEB, 0x28, 0x00, // item 2681725 (gold_1m, 「1百万」)
}
if len(live) != 14 {
t.Fatalf("fixture is not the captured 14-byte body: len=%d", len(live))
}
if got := binary.LittleEndian.Uint32(live[10:14]); got != 2681725 {
t.Fatalf("fixture lost its tier id: %d", got)
}
connection := &bufferConn{}
service := goldConsignTestService(dnfrepomemory.NewMemoryGroup())
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.searchCurrentAuctionItemsLocked(session, live); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketAuctionSearchByItemkey) {
t.Fatalf("msgID=%d want=%d", packet.Header.MsgID, dnfenum.CmdPacketAuctionSearchByItemkey)
}
// success | mode 1 | u32 total 0 | u16 count 0 - the same 7-byte empty-page
// envelope the item reader consumes, which
// TestCurrentAuctionEmptyResultBodiesMatchCurrentEXEReaders already pins for
// mode 0.
want := []byte{1, currentAuctionPayTypeConsignment, 0, 0, 0, 0, 0, 0}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("body=%x want=%x", packet.Body, want)
}
}
// The op186 diversion must claim mode 1 only, and the shared parser must keep
// rejecting mode 1 for the item panel. Both directions are pinned here so a
// later change cannot silently swap which panel owns which shape.
func TestCurrentAuctionSearchGateAndParserKeepPanelsApart(t *testing.T) {
gold := []byte{
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x01, 0xA5, 0x9C, 0x7D, 0xEB, 0x28, 0x00,
}
// The item panel appends an 8-byte detail tail; the gold panel omits it.
item := append([]byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x01, 0xB7, 0x9C, 0x95, 0x6E, 0x00, 0x00,
}, make([]byte, 8)...)
// Parser level: the shared body parser is mode-strict in both directions.
if _, items, ok := decodeCurrentAuctionSearchItemIDs(gold); ok || items != nil {
t.Fatal("item parser accepted a mode-1 gold search")
}
if _, items, ok := decodeCurrentAuctionSearchItemIDs(item); !ok || len(items) != 1 || items[0] != 28309 {
t.Fatalf("item parser no longer reads the item shape: ok=%t items=%v", ok, items)
}
if _, items, ok := decodeCurrentAuctionSearchBody(gold, currentAuctionPayTypeConsignment); !ok ||
len(items) != 1 || items[0] != 2681725 {
t.Fatalf("gold parser cannot read the captured shape: ok=%t items=%v", ok, items)
}
if _, _, ok := decodeCurrentAuctionSearchBody(item, currentAuctionPayTypeConsignment); ok {
t.Fatal("gold parser accepted a mode-0 item search")
}
modeTwo := append([]byte(nil), gold...)
modeTwo[0] = 0x02
countZero := append([]byte(nil), gold...)
countZero[7] = 0x00
// Handler level: only mode 1 is diverted. A mode-0 body keeps the item path,
// which without a repository still answers the 153 refusal it always did.
for _, test := range []struct {
name string
body []byte
}{
{"item search keeps the item path", item},
{"mode 2 is not diverted", modeTwo},
{"truncated gold search", gold[:5]},
{"gold search with count 0", countZero},
{"empty body", []byte{}},
} {
t.Run(test.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
}}
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.searchCurrentAuctionItemsLocked(session, test.body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
want := []byte{0, currentAuctionFailureCode, currentAuctionPayTypeGold}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("body=%x want=%x", packet.Body, want)
}
})
}
}
// The 金币寄售 tab 我寄售的金币 is op188 with the same one-byte body as the item
// tab but mode 1. Live capture 2026-09-21T09:43:18Z (packet_log.txt): op188 body
// `01`, answered 00 99 00 - failure code 153, the third popup.
func TestCurrentAuctionGoldOwnedItemsAcceptsLiveCaptureShape(t *testing.T) {
connection := &bufferConn{}
service := goldConsignTestService(dnfrepomemory.NewMemoryGroup())
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.sendCurrentAuctionOwnedItemsLocked(session, []byte{currentAuctionPayTypeConsignment}); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Header.MsgID != uint16(dnfenum.CmdPacketAuctionMyRegistedItemInfo) {
t.Fatalf("msgID=%d want=%d", packet.Header.MsgID, dnfenum.CmdPacketAuctionMyRegistedItemInfo)
}
// success | mode 1 | count 0 - the empty 我寄售的金币 list.
want := []byte{1, currentAuctionPayTypeConsignment, 0}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("body=%x want=%x", packet.Body, want)
}
}
// Only mode 1 may be diverted on op188. A mode-0 body keeps the item tab, which
// without a repository still answers the refusal it always did.
func TestCurrentAuctionOwnedItemsGateOnlyDivertsGoldMode(t *testing.T) {
for _, test := range []struct {
name string
body []byte
}{
{"item tab keeps the item path", []byte{currentAuctionPayTypeGold}},
{"mode 2 is not diverted", []byte{0x02}},
{"trailing byte is refused", []byte{currentAuctionPayTypeConsignment, 0x00}},
{"empty body", []byte{}},
} {
t.Run(test.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
}}
session := &gameSession{conn: connection, selectedCharacterID: 11}
if err := service.sendCurrentAuctionOwnedItemsLocked(session, test.body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
want := []byte{0, currentAuctionFailureCode, currentAuctionPayTypeGold}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("body=%x want=%x", packet.Body, want)
}
})
}
}
// ---------------------------------------------------------------------------
// Full consignment flow
// ---------------------------------------------------------------------------
// seedGoldConsignProtocolCharacter writes everything a gold handler reads: a
// character with a level and a gold wallet, its account (with whatever wallet
// metadata the case needs), and an empty inventory.
func seedGoldConsignProtocolCharacter(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID, accountID, name string, level int, gold int64, metadata map[string]string) {
t.Helper()
if metadata == nil {
metadata = map[string]string{}
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: characterID, AccountID: accountID, Name: name, Level: level,
Stats: map[string]int64{"gold": gold},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: accountID, Metadata: metadata,
}); err != nil {
t.Fatal(err)
}
}
func goldConsignProtocolGold(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID string) int64 {
t.Helper()
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
t.Fatalf("load %s: found=%t err=%v", characterID, found, err)
}
return character.Stats["gold"]
}
// goldConsignLiveRegisterBody is the exact live capture for 1百万 @ 111 Cera /
// 24h, reused by the end-to-end cases.
func goldConsignLiveRegisterBody() []byte {
return []byte{
0x01, 0x00, 0x00, 0x00, // mode 1, 0, u16 0
0x7D, 0xEB, 0x28, 0x00, // item 2681725 (gold_1m, 「1百万」)
0x01, 0x00, 0x00, 0x00, // quantity 1
0xFF, 0xFF, 0xFF, 0xFF, // bid -1
0x6F, 0x00, 0x00, 0x00, // 111 Cera
0x00, 0x00, 0x00, 0x00, // reserved
0x18, 0x00, 0x00, 0x00, // 24 hours
}
}
func goldConsignOwnerForTest(t *testing.T, repositories dnfrepo.Group) *dnfauction.GoldConsignOwner {
t.Helper()
owner, err := dnfauction.NewGoldConsignOwner(repositories)
if err != nil {
t.Fatal(err)
}
return owner
}
// The captured op183 body now escrows real gold and answers the item panel's
// own registration ACK.
func TestGoldConsignRegisterEndToEndEscrowsGold(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignProtocolCharacter(t, ctx, repositories, "11", "account-seller", "卖家", 40, 5_000_000, nil)
connection := &bufferConn{}
service := goldConsignTestService(repositories)
session := &gameSession{conn: connection, selectedCharacterID: 11, accountID: "account-seller"}
if err := service.registerCurrentAuctionItemLocked(session, goldConsignLiveRegisterBody()); err != nil {
t.Fatal(err)
}
ack, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketAuctionRegistItem) || ack.Body[0] != 1 {
t.Fatalf("register ack header=%+v body=%x", ack.Header, ack.Body)
}
// The escrow debit also republishes the seller's gold on class-0 op14.
if len(trailing) < 3 || trailing[0] != 0 || binary.LittleEndian.Uint16(trailing[1:3]) != 14 {
t.Fatalf("expected a class-0 op14 gold refresh after the ack, trailing=%x", trailing)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "11"); gold != 4_000_000 {
t.Fatalf("seller gold = %d, want 4000000", gold)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "system:auction:escrow"); gold != 1_000_000 {
t.Fatalf("escrow gold = %d, want 1000000", gold)
}
}
// Only the captured 28-byte mode-1 shape may be diverted; every neighbouring
// shape keeps the refusal it had before.
func TestGoldConsignRegisterGateOnlyDivertsTheCapturedShape(t *testing.T) {
captured := goldConsignLiveRegisterBody()
truncated := append([]byte(nil), captured[:27]...)
overlong := append(append([]byte(nil), captured...), 0x00)
quantityTwo := append([]byte(nil), captured...)
binary.LittleEndian.PutUint32(quantityTwo[8:12], 2)
badLifetime := append([]byte(nil), captured...)
binary.LittleEndian.PutUint32(badLifetime[24:28], 12)
notATier := append([]byte(nil), captured...)
binary.LittleEndian.PutUint32(notATier[4:8], 2681741)
for _, test := range []struct {
name string
body []byte
}{
{"exact capture minus one byte", truncated},
{"exact capture plus one byte", overlong},
{"quantity is not fixed at 1", quantityTwo},
{"lifetime outside 24/48", badLifetime},
{"item is not a denomination", notATier},
{"empty body", []byte{}},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignProtocolCharacter(t, ctx, repositories, "11", "account-seller", "Seller", 40, 5_000_000, nil)
connection := &bufferConn{}
service := goldConsignTestService(repositories)
session := &gameSession{conn: connection, selectedCharacterID: 11, accountID: "account-seller"}
if err := service.registerCurrentAuctionItemLocked(session, test.body); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Body[0] != 0 {
t.Fatalf("a malformed consignment was accepted: body=%x", packet.Body)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "11"); gold != 5_000_000 {
t.Fatalf("refused register moved gold: %d", gold)
}
})
}
}
// Cancel returns the gold as a letter rather than into the bag, and the row
// leaves the shop.
func TestGoldConsignCancelSendsGoldHomeByLetter(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
seedGoldConsignProtocolCharacter(t, ctx, repositories, "11", "account-seller", "Seller", 40, 5_000_000, nil)
connection := &bufferConn{}
service := goldConsignTestService(repositories)
session := &gameSession{conn: connection, selectedCharacterID: 11, accountID: "account-seller"}
if err := service.registerCurrentAuctionItemLocked(session, goldConsignLiveRegisterBody()); err != nil {
t.Fatal(err)
}
listingID := service.nextAuctionListingID.Load()
connection.write.Reset()
cancel := make([]byte, currentAuctionGoldCancelRequestSize)
cancel[0] = currentAuctionPayTypeConsignment
binary.LittleEndian.PutUint64(cancel[1:9], listingID)
if err := service.cancelCurrentAuctionItemLocked(session, cancel); err != nil {
t.Fatal(err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 {
t.Fatalf("trailing=%x", trailing)
}
if packet.Body[0] != 1 || packet.Body[1] != currentAuctionPayTypeConsignment {
t.Fatalf("cancel ack body=%x", packet.Body)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "system:auction:escrow"); gold != 0 {
t.Fatalf("escrow after cancel = %d, want 0", gold)
}
// The seller's wallet is credited on claim, not on cancel.
if gold := goldConsignProtocolGold(t, ctx, repositories, "11"); gold != 4_000_000 {
t.Fatalf("seller gold after cancel = %d, want 4000000", gold)
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "11")
if err != nil || !found {
t.Fatalf("mailbox: found=%t err=%v", found, err)
}
wantKey := "goldconsign:refund:goldconsign_cancel_return:" + strconv.FormatUint(listingID, 10)
refunded := false
for _, mail := range mailbox.Mails {
if mail.Metadata["completion_key"] == wantKey {
refunded = true
if mail.Gold != 1_000_000 {
t.Fatalf("refund letter carries %d gold, want 1000000", mail.Gold)
}
}
}
if !refunded {
t.Fatalf("no refund letter was enqueued; mails=%+v", mailbox.Mails)
}
}
// The buy saga takes Cera from the buyer and enqueues a gold letter for them
// plus a 代币券 letter for the seller.
func TestGoldConsignBuyTakesCeraAndPaysTokenCeraByLetter(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"})
connection := &bufferConn{}
service := goldConsignTestService(repositories)
seller := &gameSession{conn: connection, selectedCharacterID: 11, accountID: "account-seller"}
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"}
buy := make([]byte, currentAuctionGoldBuyRequestSize)
buy[0] = currentAuctionPayTypeConsignment
binary.LittleEndian.PutUint32(buy[1:5], 111)
binary.LittleEndian.PutUint64(buy[5:13], listingID)
if err := service.buyCurrentAuctionItemLocked(buyer, uint16(dnfenum.CmdPacketAuctionBidding), buy, false); err != nil {
t.Fatal(err)
}
ack, trailing := splitGameServerUpperPacket(t, buyerConn.write.Bytes())
if ack.Body[0] != 1 || ack.Body[1] != currentAuctionPayTypeConsignment {
t.Fatalf("buy ack body=%x", ack.Body)
}
// The Cera debit also republishes the buyer's wallet on class-0 op53.
if len(trailing) < 3 || trailing[0] != 0 || binary.LittleEndian.Uint16(trailing[1:3]) != currentCeraShopUpdateMsgID {
t.Fatalf("expected a class-0 op%d wallet refresh after the ack, trailing=%x",
currentCeraShopUpdateMsgID, trailing)
}
// 111 Cera at the base 5% leaves the seller 105: 86 floors the proceeds.
buyerAccount, found, err := repositories.Account.Load(ctx, "account-buyer")
if err != nil || !found {
t.Fatal(err)
}
if got := buyerAccount.Metadata["account_cera"]; got != "4889" {
t.Fatalf("buyer Cera = %s, want 4889", got)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "system:auction:escrow"); gold != 0 {
t.Fatalf("escrow after sale = %d, want 0", gold)
}
buyerBox, _, err := repositories.Mailbox.Load(ctx, "22")
if err != nil {
t.Fatal(err)
}
buyerLetter := false
for _, mail := range buyerBox.Mails {
if mail.Gold == 1_000_000 {
buyerLetter = true
}
}
if !buyerLetter {
t.Fatalf("buyer got no gold letter: %+v", buyerBox.Mails)
}
sellerBox, _, err := repositories.Mailbox.Load(ctx, "11")
if err != nil {
t.Fatal(err)
}
voucherLetter := false
for _, mail := range sellerBox.Mails {
for _, attachment := range mail.Attachments {
if attachment.ItemID == 2681762 && attachment.Count == 105 {
voucherLetter = true
}
}
}
if !voucherLetter {
t.Fatalf("seller got no 代币券 letter: %+v", sellerBox.Mails)
}
// The sale now feeds the fee history.
owner := goldConsignOwnerForTest(t, repositories)
if average, samples, err := owner.AverageCeraPrice(ctx, 2681725); err != nil || average != 111 || samples != 1 {
t.Fatalf("average after the sale = %d/%d err=%v, want 111/1", average, samples, err)
}
}
// A buyer who cannot pay is refused, and the row stays on sale.
func TestGoldConsignBuyRefusesWithoutCeraAndLeavesTheRowOnSale(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": "50"})
connection := &bufferConn{}
service := goldConsignTestService(repositories)
seller := &gameSession{conn: connection, selectedCharacterID: 11, accountID: "account-seller"}
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"}
buy := make([]byte, currentAuctionGoldBuyRequestSize)
buy[0] = currentAuctionPayTypeConsignment
binary.LittleEndian.PutUint32(buy[1:5], 111)
binary.LittleEndian.PutUint64(buy[5:13], listingID)
if err := service.buyCurrentAuctionItemLocked(buyer, uint16(dnfenum.CmdPacketAuctionBidding), buy, false); err != nil {
t.Fatal(err)
}
packet, _ := splitGameServerUpperPacket(t, buyerConn.write.Bytes())
if packet.Body[0] != 0 {
t.Fatalf("an unaffordable buy succeeded: body=%x", packet.Body)
}
if gold := goldConsignProtocolGold(t, ctx, repositories, "system:auction:escrow"); gold != 1_000_000 {
t.Fatalf("a refused buy released the escrow: %d", gold)
}
owner := goldConsignOwnerForTest(t, repositories)
live, err := owner.Search(ctx, nil)
if err != nil {
t.Fatal(err)
}
if len(live) != 1 || live[0].Status != dnfauction.GoldConsignActive {
t.Fatalf("a refused buy left the row unavailable: %+v", live)
}
}