DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,547 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user