服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
548 lines
22 KiB
Go
548 lines
22 KiB
Go
package auction
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
// seedGoldConsignCharacter writes a character with the level the consignment
|
|
// gates look at, plus a gold wallet.
|
|
func seedGoldConsignCharacter(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID, accountID, name string, level int, gold int64) {
|
|
t.Helper()
|
|
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: map[string]string{},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func goldConsignCharGold(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 character %s: found=%t err=%v", characterID, found, err)
|
|
}
|
|
return character.Stats["gold"]
|
|
}
|
|
|
|
func goldConsignEscrowGold(t *testing.T, ctx context.Context, repositories dnfrepo.Group) int64 {
|
|
t.Helper()
|
|
character, found, err := repositories.Character.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
t.Fatalf("load escrow: found=%t err=%v", found, err)
|
|
}
|
|
return character.Stats[GoldConsignEscrowGoldKey]
|
|
}
|
|
|
|
func mustGoldConsignOwner(t *testing.T, repositories dnfrepo.Group) *GoldConsignOwner {
|
|
t.Helper()
|
|
owner, err := NewGoldConsignOwner(repositories)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return owner
|
|
}
|
|
|
|
// The table is the client's own, read off NoPack.exe .rdata VA 0x4580AA8. The
|
|
// ids are deliberately NOT contiguous, so this test exists to stop anybody
|
|
// "simplifying" it into a base+index formula that would misprice five tiers.
|
|
func TestGoldConsignTierTableMatchesTheClientImage(t *testing.T) {
|
|
expected := []GoldConsignTier{
|
|
{2681725, 1_000_000}, {2681726, 2_000_000}, {2681727, 3_000_000},
|
|
{2681728, 4_000_000}, {2681729, 5_000_000}, {2681730, 6_000_000},
|
|
{2681731, 7_000_000}, {2681732, 8_000_000}, {2681733, 9_000_000},
|
|
{2681734, 10_000_000}, {2681735, 20_000_000}, {2681736, 30_000_000},
|
|
{2683069, 40_000_000}, {2683070, 50_000_000}, {2683071, 100_000_000},
|
|
{2683072, 150_000_000}, {2683073, 200_000_000},
|
|
}
|
|
got := GoldConsignTiers()
|
|
if len(got) != 17 || len(got) != len(expected) {
|
|
t.Fatalf("tier count = %d, want 17", len(got))
|
|
}
|
|
for index := range expected {
|
|
if got[index] != expected[index] {
|
|
t.Fatalf("tier %d = %+v, want %+v", index, got[index], expected[index])
|
|
}
|
|
}
|
|
// The gap is the whole point: 2681737 is NOT a tier, and neither is
|
|
// 2681741, which a contiguous formula would happily resolve.
|
|
for _, absent := range []int64{2681724, 2681737, 2681741, 2683068, 2683074, 0, -1} {
|
|
if IsGoldConsignTier(absent) {
|
|
t.Fatalf("item %d must not be a gold tier", absent)
|
|
}
|
|
}
|
|
if amount, ok := GoldConsignTierAmount(2683073); !ok || amount != 200_000_000 {
|
|
t.Fatalf("top tier = %d ok=%t, want 200000000", amount, ok)
|
|
}
|
|
}
|
|
|
|
// The official commission table, including its boundaries: the first row reads
|
|
// 「40% 以下」, so exactly 40% is still listable.
|
|
func TestResolveGoldConsignFeeFollowsTheOfficialTable(t *testing.T) {
|
|
const average = 1000
|
|
for _, test := range []struct {
|
|
name string
|
|
price int64
|
|
coupon bool
|
|
wantFee int64
|
|
wantListed bool
|
|
}{
|
|
{"below 40% is refused", 399, false, 0, false},
|
|
{"below 40% is refused with a coupon too", 399, true, 0, false},
|
|
{"exactly 40% lists", 400, false, 5, true},
|
|
{"exactly 40% lists at the coupon rate", 400, true, 1, true},
|
|
{"mid band", 900, false, 5, true},
|
|
{"exactly 150% is still the base band", 1500, false, 5, true},
|
|
{"exactly 150% takes the coupon", 1500, true, 1, true},
|
|
{"just over 150% jumps to 30%", 1501, false, 30, true},
|
|
{"the coupon cannot help above 150%", 1501, true, 30, true},
|
|
{"exactly 160%", 1600, false, 30, true},
|
|
{"just over 160%", 1601, false, 40, true},
|
|
{"exactly 170%", 1700, false, 40, true},
|
|
{"just over 170%", 1701, false, 50, true},
|
|
{"exactly 180%", 1800, false, 50, true},
|
|
{"just over 180%", 1801, false, 95, true},
|
|
{"the coupon cannot help in the 95% band either", 9999, true, 95, true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fee, listable := ResolveGoldConsignFee(test.price, average, test.coupon)
|
|
if listable != test.wantListed || fee != test.wantFee {
|
|
t.Fatalf("fee=%d listable=%t, want fee=%d listable=%t",
|
|
fee, listable, test.wantFee, test.wantListed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// With no sales the average is 0 and the table has no meaning. The operator's
|
|
// decision is to keep selling at the ordinary band rather than divide by zero
|
|
// or dump every listing into the 95% band.
|
|
func TestGoldConsignNoSalesFallsBackToTheBaseFee(t *testing.T) {
|
|
if fee, listable := ResolveGoldConsignFee(1, 0, false); !listable || fee != GoldConsignBaseFeePercent {
|
|
t.Fatalf("no-history fee=%d listable=%t, want %d/true", fee, listable, GoldConsignBaseFeePercent)
|
|
}
|
|
if fee, listable := ResolveGoldConsignFee(999_999, 0, true); !listable || fee != GoldConsignCouponFeePercent {
|
|
t.Fatalf("no-history coupon fee=%d listable=%t, want %d/true", fee, listable, GoldConsignCouponFeePercent)
|
|
}
|
|
// A non-positive price is never listable, average or not.
|
|
if _, listable := ResolveGoldConsignFee(0, 0, false); listable {
|
|
t.Fatal("a zero price must not be listable")
|
|
}
|
|
}
|
|
|
|
func TestGoldConsignProceedsApplyTheFrozenCommission(t *testing.T) {
|
|
for _, test := range []struct{ price, fee, want int64 }{
|
|
{111, 5, 105},
|
|
{111, 1, 109},
|
|
{100, 30, 70},
|
|
{100, 95, 5},
|
|
{100, 100, 0},
|
|
{0, 5, 0},
|
|
} {
|
|
if got := GoldConsignProceeds(test.price, test.fee); got != test.want {
|
|
t.Fatalf("proceeds(%d,%d) = %d, want %d", test.price, test.fee, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register escrows the consigned gold itself - no extra deposit - and writes
|
|
// one row whose frozen fee is what the seller was told at registration.
|
|
func TestGoldConsignRegisterEscrowsGoldAndFreezesTheFee(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "卖家", 40, 50_000_000)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
|
|
result, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 1, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Listing.GoldAmount != 1_000_000 {
|
|
t.Fatalf("escrowed amount = %d, want 1000000", result.Listing.GoldAmount)
|
|
}
|
|
if result.Listing.SellerName != "卖家" {
|
|
t.Fatalf("seller name = %q, want the character row's own", result.Listing.SellerName)
|
|
}
|
|
// No sales yet, so the ordinary band applies and the coupon is irrelevant.
|
|
if result.Listing.FeePercent != GoldConsignBaseFeePercent {
|
|
t.Fatalf("frozen fee = %d, want %d", result.Listing.FeePercent, GoldConsignBaseFeePercent)
|
|
}
|
|
if result.Listing.ProceedsCera != 105 {
|
|
t.Fatalf("proceeds = %d, want 105", result.Listing.ProceedsCera)
|
|
}
|
|
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != 49_000_000 {
|
|
t.Fatalf("seller gold = %d, want 49000000", gold)
|
|
}
|
|
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 1_000_000 {
|
|
t.Fatalf("escrow gold = %d, want 1000000", gold)
|
|
}
|
|
if result.Listing.ExpiresAt.Sub(result.Listing.CreatedAt) != 24*time.Hour {
|
|
t.Fatalf("lifetime = %v, want 24h", result.Listing.ExpiresAt.Sub(result.Listing.CreatedAt))
|
|
}
|
|
// The gold is conserved: seller + escrow is what the seller started with.
|
|
if goldConsignCharGold(t, ctx, repositories, "seller")+goldConsignEscrowGold(t, ctx, repositories) != 50_000_000 {
|
|
t.Fatal("gold was created or destroyed by the escrow move")
|
|
}
|
|
}
|
|
|
|
func TestGoldConsignRegisterRefusesWhatItCannotHonour(t *testing.T) {
|
|
ctx := context.Background()
|
|
cases := []struct {
|
|
name string
|
|
cmd GoldConsignRegisterCommand
|
|
gold int64
|
|
level int
|
|
want error
|
|
}{
|
|
{
|
|
name: "seller below level 30",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 111, DurationHour: 24},
|
|
gold: 50_000_000, level: 29, want: ErrGoldConsignSellerLevel,
|
|
},
|
|
{
|
|
name: "item is not a denomination",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681741, PriceCera: 111, DurationHour: 24},
|
|
gold: 50_000_000, level: 40, want: ErrGoldConsignNotTier,
|
|
},
|
|
{
|
|
name: "not enough gold to consign",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681736, PriceCera: 111, DurationHour: 24},
|
|
gold: 29_999_999, level: 40, want: ErrGoldUnavailable,
|
|
},
|
|
{
|
|
name: "lifetime outside 24/48",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 111, DurationHour: 12},
|
|
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
|
|
},
|
|
{
|
|
name: "non-positive price",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "account-seller", GoldItemID: 2681725, PriceCera: 0, DurationHour: 24},
|
|
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
|
|
},
|
|
{
|
|
name: "wrong account",
|
|
cmd: GoldConsignRegisterCommand{ListingID: 1, SellerID: "seller", AccountID: "someone-else", GoldItemID: 2681725, PriceCera: 111, DurationHour: 24},
|
|
gold: 50_000_000, level: 40, want: ErrInvalidRequest,
|
|
},
|
|
}
|
|
for _, test := range cases {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", test.level, test.gold)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
if _, err := owner.Register(ctx, test.cmd); !errors.Is(err, test.want) {
|
|
t.Fatalf("err = %v, want %v", err, test.want)
|
|
}
|
|
// A refusal must not move any gold.
|
|
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != test.gold {
|
|
t.Fatalf("refused register moved gold: %d, want %d", gold, test.gold)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGoldConsignCancelReturnsTheGoldAndDropsTheRow(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 50_000_000)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 7, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681735, PriceCera: 900, DurationHour: 48,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 20_000_000 {
|
|
t.Fatalf("escrow before cancel = %d, want 20000000", gold)
|
|
}
|
|
result, err := owner.Cancel(ctx, "seller", "account-seller", 7)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.RefundGold != 20_000_000 {
|
|
t.Fatalf("refund = %d, want 20000000", result.RefundGold)
|
|
}
|
|
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
|
|
t.Fatalf("escrow after cancel = %d, want 0", gold)
|
|
}
|
|
// The refund goes home by letter, so the seller's wallet is untouched here
|
|
// and the gold sits in no wallet at all until the letter is claimed.
|
|
if gold := goldConsignCharGold(t, ctx, repositories, "seller"); gold != 30_000_000 {
|
|
t.Fatalf("seller gold after cancel = %d, want 30000000 (still unredeemed)", gold)
|
|
}
|
|
if _, err := owner.LoadListing(ctx, 7); !errors.Is(err, ErrListingMissing) {
|
|
t.Fatalf("cancelled row still loads: %v", err)
|
|
}
|
|
// Cancelling twice is a missing row, not a second refund.
|
|
if _, err := owner.Cancel(ctx, "seller", "account-seller", 7); !errors.Is(err, ErrListingMissing) {
|
|
t.Fatalf("second cancel err = %v, want ErrListingMissing", err)
|
|
}
|
|
// Somebody else's cancel is refused.
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 8, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := owner.Cancel(ctx, "someone-else", "someone-else", 8); !errors.Is(err, ErrNotOwner) {
|
|
t.Fatalf("foreign cancel err = %v, want ErrNotOwner", err)
|
|
}
|
|
}
|
|
|
|
// The buy saga: reserve, settle, and the sold row that becomes both the
|
|
// delivery intent and the fee history.
|
|
func TestGoldConsignBuySagaSettlesAndFeedsTheAverage(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 10_000_000)
|
|
seedGoldConsignCharacter(t, ctx, repositories, "buyer", "account-buyer", "Buyer", 20, 0)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 3, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if average, samples, err := owner.AverageCeraPrice(ctx, 2681725); err != nil || average != 0 || samples != 0 {
|
|
t.Fatalf("average before any sale = %d/%d err=%v, want 0/0", average, samples, err)
|
|
}
|
|
|
|
reservation, err := owner.Reserve(ctx, "buyer", "account-buyer", 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reservation.Listing.Status != GoldConsignReserved || reservation.Listing.BuyerID != "buyer" {
|
|
t.Fatalf("reservation = %+v", reservation.Listing)
|
|
}
|
|
// The row is out of the shop while reserved, so nobody else can take it.
|
|
live, err := owner.Search(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(live) != 0 {
|
|
t.Fatalf("a reserved row must not stay on sale: %+v", live)
|
|
}
|
|
if _, err := owner.Reserve(ctx, "buyer", "account-buyer", 3); !errors.Is(err, ErrGoldConsignNotActive) {
|
|
t.Fatalf("double reserve err = %v, want ErrGoldConsignNotActive", err)
|
|
}
|
|
|
|
sold, err := owner.Settle(ctx, "buyer", 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sold.Status != GoldConsignSold || sold.ProceedsCera != 105 {
|
|
t.Fatalf("settled = %+v, want sold with 105 proceeds", sold)
|
|
}
|
|
// The gold has left the escrow wallet for the buyer's letter.
|
|
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
|
|
t.Fatalf("escrow after settle = %d, want 0", gold)
|
|
}
|
|
// And it is now the fee history the next registration reads.
|
|
average, samples, err := owner.AverageCeraPrice(ctx, 2681725)
|
|
if err != nil || average != 111 || samples != 1 {
|
|
t.Fatalf("average after one sale = %d/%d err=%v, want 111/1", average, samples, err)
|
|
}
|
|
pending, err := owner.UndeliveredSales(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pending) != 1 || pending[0].ID != 3 || pending[0].Delivered {
|
|
t.Fatalf("undelivered sales = %+v", pending)
|
|
}
|
|
// 第一次盖章才算跃迁(freshlyDelivered=true)——记录侧全靠这一位保证只写一条。
|
|
freshlyDelivered, err := owner.MarkDelivered(ctx, "buyer", 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !freshlyDelivered {
|
|
t.Fatal("首次 MarkDelivered 必须报告 freshlyDelivered=true")
|
|
}
|
|
// 重放同一次盖章:行已经盖过,必须报告 false(否则记录会多出一条成交收款)。
|
|
if again, err := owner.MarkDelivered(ctx, "buyer", 3); err != nil || again {
|
|
t.Fatalf("重放 MarkDelivered = %t/%v, want false/nil", again, err)
|
|
}
|
|
pending, err = owner.UndeliveredSales(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pending) != 0 {
|
|
t.Fatalf("stamped sale still pending: %+v", pending)
|
|
}
|
|
// Releasing a settled row must not resurrect it.
|
|
if err := owner.Release(ctx, "buyer", 3); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := owner.Settle(ctx, "buyer", 3); !errors.Is(err, ErrListingMissing) {
|
|
t.Fatalf("re-settle err = %v, want ErrListingMissing", err)
|
|
}
|
|
}
|
|
|
|
// Once a sale exists the bands switch on by themselves, which is the whole
|
|
// point of starting the average at zero.
|
|
func TestGoldConsignBandsActivateOnceSalesExist(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 100_000_000)
|
|
seedGoldConsignCharacter(t, ctx, repositories, "buyer", "account-buyer", "Buyer", 20, 0)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
// Seed the history directly: one 1百万 sale at 1000 Cera.
|
|
box := GoldConsignListing{
|
|
ID: 900, SellerID: "seller", GoldItemID: 2681725, GoldAmount: 1_000_000,
|
|
PriceCera: 1000, FeePercent: 5, ProceedsCera: 950, Status: GoldConsignSold,
|
|
CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
}
|
|
if err := owner.EnsureEscrow(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, found, err := repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Slots == nil {
|
|
stored.Slots = map[string]dnfrepo.ItemStack{}
|
|
}
|
|
stack := goldConsignRowStack(box)
|
|
stack.Extra["sold_at"] = "1"
|
|
stored.Slots[goldConsignSoldKey(box.ID)] = stack
|
|
if err := repositories.Inventory.Save(ctx, stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if average, samples, err := owner.AverageCeraPrice(ctx, 2681725); err != nil || average != 1000 || samples != 1 {
|
|
t.Fatalf("seeded average = %d/%d err=%v, want 1000/1", average, samples, err)
|
|
}
|
|
// 300 is below 40% of 1000, so the table refuses the listing outright.
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 11, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 300, DurationHour: 24,
|
|
}); !errors.Is(err, ErrGoldConsignPriceRejected) {
|
|
t.Fatalf("below-band register err = %v, want ErrGoldConsignPriceRejected", err)
|
|
}
|
|
// 400 is exactly 40% and lists at the base rate.
|
|
result, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 12, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 400, DurationHour: 24,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Listing.FeePercent != GoldConsignBaseFeePercent {
|
|
t.Fatalf("40%% band fee = %d, want %d", result.Listing.FeePercent, GoldConsignBaseFeePercent)
|
|
}
|
|
// 2000 is 200% of the average, which is the 95% band.
|
|
steep, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 13, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 2000, DurationHour: 24,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if steep.Listing.FeePercent != 95 {
|
|
t.Fatalf("200%% band fee = %d, want 95", steep.Listing.FeePercent)
|
|
}
|
|
}
|
|
|
|
func TestGoldConsignExpiryReturnsTheGoldAndClearsTheRow(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 5_000_000)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
// A past expiry is produced by moving the ledger's clock back far enough
|
|
// that Register's own 24h lifetime has already run out: 30h ago plus 24h
|
|
// leaves the row six hours stale.
|
|
owner.now = func() time.Time { return time.Now().UTC().Add(-30 * time.Hour) }
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 21, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
owner.now = time.Now
|
|
expired, err := owner.ReturnExpired(ctx, "seller", "account-seller")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(expired) != 1 || expired[0].Refund != 1_000_000 {
|
|
t.Fatalf("expired = %+v", expired)
|
|
}
|
|
if gold := goldConsignEscrowGold(t, ctx, repositories); gold != 0 {
|
|
t.Fatalf("escrow after expiry = %d, want 0", gold)
|
|
}
|
|
again, err := owner.ReturnExpired(ctx, "seller", "account-seller")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(again) != 0 {
|
|
t.Fatalf("a second sweep re-returned %+v", again)
|
|
}
|
|
}
|
|
|
|
// A buyer may not take their own consignment, and a buyer below level 20 is
|
|
// refused before any money moves.
|
|
func TestGoldConsignBuyRefusesSelfDealingAndLowLevel(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 5_000_000)
|
|
seedGoldConsignCharacter(t, ctx, repositories, "lowbie", "account-lowbie", "Lowbie", 19, 0)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 31, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := owner.Reserve(ctx, "seller", "account-seller", 31); !errors.Is(err, ErrGoldConsignBuyerIsSeller) {
|
|
t.Fatalf("self-buy err = %v, want ErrGoldConsignBuyerIsSeller", err)
|
|
}
|
|
if _, err := owner.Reserve(ctx, "lowbie", "account-lowbie", 31); !errors.Is(err, ErrGoldConsignBuyerLevel) {
|
|
t.Fatalf("low-level buy err = %v, want ErrGoldConsignBuyerLevel", err)
|
|
}
|
|
// Neither refusal may have taken the row off sale.
|
|
live, err := owner.Search(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(live) != 1 {
|
|
t.Fatalf("refused reserves changed the listing set: %+v", live)
|
|
}
|
|
}
|
|
|
|
func TestGoldConsignActiveListingLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
seedGoldConsignCharacter(t, ctx, repositories, "seller", "account-seller", "Seller", 40, 50_000_000)
|
|
owner := mustGoldConsignOwner(t, repositories)
|
|
for index := 0; index < GoldConsignMaxActiveListings; index++ {
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: uint64(100 + index), SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); err != nil {
|
|
t.Fatalf("listing %d: %v", index, err)
|
|
}
|
|
}
|
|
if _, err := owner.Register(ctx, GoldConsignRegisterCommand{
|
|
ListingID: 200, SellerID: "seller", AccountID: "account-seller",
|
|
GoldItemID: 2681725, PriceCera: 111, DurationHour: 24,
|
|
}); !errors.Is(err, ErrGoldConsignLimit) {
|
|
t.Fatalf("over-limit register err = %v, want ErrGoldConsignLimit", err)
|
|
}
|
|
}
|