服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
643 lines
25 KiB
Go
643 lines
25 KiB
Go
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)
|
|
}
|
|
}
|
|
|