服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2048 lines
76 KiB
Go
2048 lines
76 KiB
Go
package auction
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
EscrowCharacterID = "system:auction:escrow"
|
|
EscrowAccountID = "system:auction"
|
|
listingPrefix = "auction:listing:"
|
|
payoutPrefix = "auction:payout:"
|
|
deliveryPrefix = "auction:delivery:"
|
|
)
|
|
|
|
// Auction charges, following 86JP's DefaultAuctionListingPolicy: a flat
|
|
// refundable deposit taken when the listing is registered, plus a commission
|
|
// on the sale price. The 拍卖行优惠券 (premium type 29) advertises
|
|
// "减少使用拍卖行所需的手续费" in its own PVF explain text without naming a
|
|
// number, so the discounted rate is an operator setting rather than a decoded
|
|
// constant. The deposit is not discounted: it is returned in full on a cancel,
|
|
// on expiry, and with the proceeds once a listing sells out.
|
|
const (
|
|
ListingDepositGold int64 = 10000
|
|
SaleFeePercent int64 = 5
|
|
DiscountedSaleFeePercent int64 = 3
|
|
)
|
|
|
|
// SaleFeePercentFor picks the commission a registration freezes into its
|
|
// listing. Freezing it beats reading the seller's coupon when the sale
|
|
// settles: the buyer's transaction never has to reach across into the
|
|
// seller's account, and a coupon expiring mid-listing cannot silently change
|
|
// terms the seller already accepted.
|
|
func SaleFeePercentFor(hasAuctionCoupon bool) int64 {
|
|
if hasAuctionCoupon {
|
|
return DiscountedSaleFeePercent
|
|
}
|
|
return SaleFeePercent
|
|
}
|
|
|
|
var (
|
|
ErrUnavailable = errors.New("auction repository is unavailable")
|
|
ErrInvalidRequest = errors.New("auction request is invalid")
|
|
ErrListingMissing = errors.New("auction listing is missing")
|
|
ErrNotOwner = errors.New("auction listing owner mismatch")
|
|
ErrItemUnavailable = errors.New("auction item is unavailable")
|
|
ErrInventoryFull = errors.New("auction destination inventory is full")
|
|
ErrGoldUnavailable = errors.New("auction gold is unavailable")
|
|
ErrListingExpired = errors.New("auction listing is expired")
|
|
ErrListingIDExists = errors.New("auction listing id already exists")
|
|
ErrListingLimit = errors.New("auction listing limit reached")
|
|
// ErrPriceMismatch is a payment that is neither the buyout total nor a
|
|
// sub-buyout bid - an overpayment, or a per-piece purchase whose money
|
|
// field does not match the count it asked for.
|
|
ErrPriceMismatch = errors.New("auction payment does not match the listing price")
|
|
// ErrBuyerIsSeller is the seller trying to buy or bid on their own
|
|
// consignment. 86JP separates it (AuctionApplicationError.BuyerIsSeller)
|
|
// because the client has its own text for it.
|
|
ErrBuyerIsSeller = errors.New("auction buyer is the seller")
|
|
// ErrBiddingNotAllowed is a bid on a listing the seller registered
|
|
// buyout-only: with no opening bid there is nothing to bid against.
|
|
ErrBiddingNotAllowed = errors.New("auction listing does not accept bids")
|
|
// ErrBidTooLow is a bid that does not beat what the listing already holds:
|
|
// at or below the standing bid, below the opening bid when there is none
|
|
// yet, or at/above the buyout - that last one has to go through the buyout
|
|
// instead, exactly as 86JP's AuctionBidService.TryBid refuses it.
|
|
ErrBidTooLow = errors.New("auction bid does not beat the standing bid")
|
|
)
|
|
|
|
type Listing struct {
|
|
ID uint64
|
|
SellerID string
|
|
SellerName string
|
|
Item dnfrepo.ItemStack
|
|
ListType byte
|
|
SourceSlot int16
|
|
UnitPrice int64
|
|
// StartPrice is the seller's per-item opening bid, or -1 for a listing
|
|
// registered buyout-only. The client sends it in the op183 registration and
|
|
// renders it as 当前竞拍价; dropping it made that control read back the
|
|
// buyout instead. Every capture so far carries -1, so -1 is also what a row
|
|
// written before this field existed decodes to.
|
|
StartPrice int64
|
|
// BuyoutPrice is the seller's per-item 一口价, or -1 when the listing was
|
|
// registered with an opening bid only. The client treats both price
|
|
// controls as independently optional and refuses just the both-empty case,
|
|
// so a listing can legitimately have no buyout; UnitPrice then holds the
|
|
// opening bid, which is the price the listing actually sells at. A row
|
|
// escrowed before this field existed always had a buyout, so a missing key
|
|
// decodes to UnitPrice.
|
|
BuyoutPrice int64
|
|
// Deposit is the refundable gold this registration escrowed, and
|
|
// FeePercent the sale commission it froze. Both are zero on a row written
|
|
// before auction charges existed, which is exactly right: those listings
|
|
// were free to register and must stay free to settle.
|
|
Deposit int64
|
|
FeePercent int64
|
|
// BidTotal is the standing top bid for the whole listing in gold, 0 when
|
|
// nobody has bid yet, and BidderID/BidderAccountID who placed it. The gold
|
|
// is already out of the bidder's wallet and sitting in the escrow wallet,
|
|
// so every path that ends this listing has to either pay it to the seller
|
|
// (the bidder won) or refund it (outbid, bought out from under, cancelled,
|
|
// or the listing was pulled).
|
|
BidTotal int64
|
|
BidderID string
|
|
BidderAccountID string
|
|
BidAt time.Time
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// HasBidding reports whether the seller offered this listing for bidding at
|
|
// all. 86JP gates its whole bid path on the same term (`StartBidPrice > 0`):
|
|
// a buyout-only registration has no opening bid, so there is nothing to bid
|
|
// against and the buyout is the only way to take it.
|
|
func (l Listing) HasBidding() bool {
|
|
return l.StartPrice > 0
|
|
}
|
|
|
|
// OpeningBidTotal is the least a first bid may be, and BuyoutTotal what the
|
|
// buyout costs for the whole listing (0 when there is none). Both prices are
|
|
// stored per item while every wire amount is a total, so the multiplication
|
|
// belongs here rather than at each caller.
|
|
func (l Listing) OpeningBidTotal() int64 {
|
|
return listingTotal(l.BidUnitPrice(), l.Item.Count)
|
|
}
|
|
|
|
func (l Listing) BuyoutTotal() int64 {
|
|
if !l.HasBuyout() {
|
|
return 0
|
|
}
|
|
return listingTotal(l.BuyoutUnitPrice(), l.Item.Count)
|
|
}
|
|
|
|
// DisplayBidTotal is what the client's 当前竞拍价 slot shows: the standing bid
|
|
// once there is one, otherwise the seller's opening bid. Same term as 86JP's
|
|
// AuctionListingRecord.DisplayBidPrice.
|
|
func (l Listing) DisplayBidTotal() int64 {
|
|
if l.BidTotal > 0 {
|
|
return l.BidTotal
|
|
}
|
|
return l.OpeningBidTotal()
|
|
}
|
|
|
|
func listingTotal(unitPrice, count int64) int64 {
|
|
if unitPrice <= 0 || count <= 0 {
|
|
return 0
|
|
}
|
|
if unitPrice > math.MaxInt64/count {
|
|
return math.MaxInt64
|
|
}
|
|
return unitPrice * count
|
|
}
|
|
|
|
// SaleFee is the commission this listing owes on a given sale price, and
|
|
// SellerProceeds what is left for the seller once it is taken. The deposit
|
|
// comes back only when the listing is fully consumed, matching 86JP.
|
|
func (l Listing) SaleFee(salePrice int64) int64 {
|
|
if salePrice <= 0 || l.FeePercent <= 0 {
|
|
return 0
|
|
}
|
|
if salePrice > math.MaxInt64/l.FeePercent {
|
|
// Only reachable on a price no wallet can hold; keep the rounding
|
|
// direction rather than overflowing.
|
|
return salePrice / 100 * l.FeePercent
|
|
}
|
|
return salePrice * l.FeePercent / 100
|
|
}
|
|
|
|
func (l Listing) SellerProceeds(salePrice int64, listingCompleted bool) int64 {
|
|
proceeds := salePrice - l.SaleFee(salePrice)
|
|
if listingCompleted {
|
|
proceeds += l.Deposit
|
|
}
|
|
return proceeds
|
|
}
|
|
|
|
// SaleStatement is the money breakdown the seller's 成交 letter prints, frozen
|
|
// at the moment the sale settles:
|
|
//
|
|
// [已售出 金刚石 1个。]
|
|
// 出售价 : + 10000 金币
|
|
// 保证金 : + 10000 金币
|
|
// 手续费 : - 500 金币
|
|
// 已售出 金刚石 并获得 19500 金币。
|
|
//
|
|
// It is stored on the payout row rather than recomputed at mail time: the
|
|
// listing row is gone once the last piece sells, and the letter has to read the
|
|
// same on the first attempt and on a deferred retry.
|
|
type SaleStatement struct {
|
|
// GrossGold is what the item sold for, before the commission.
|
|
GrossGold int64
|
|
// FeeGold is the commission taken out of it.
|
|
FeeGold int64
|
|
// DepositGold is the registration deposit that came home with the proceeds,
|
|
// and is zero on a partial sale - the deposit only returns once the whole
|
|
// listing is gone.
|
|
DepositGold int64
|
|
// NetGold is what the letter actually pays: gross - fee + deposit.
|
|
NetGold int64
|
|
}
|
|
|
|
// SaleStatementFor freezes the breakdown for one settlement.
|
|
func (l Listing) SaleStatementFor(salePrice int64, listingCompleted bool) SaleStatement {
|
|
deposit := int64(0)
|
|
if listingCompleted {
|
|
deposit = l.Deposit
|
|
}
|
|
return SaleStatement{
|
|
GrossGold: salePrice,
|
|
FeeGold: l.SaleFee(salePrice),
|
|
DepositGold: deposit,
|
|
NetGold: l.SellerProceeds(salePrice, listingCompleted),
|
|
}
|
|
}
|
|
|
|
// HasBuyout reports whether the seller set a 一口价. Only -1 means there is
|
|
// none: the struct's zero value is what every construction site written before
|
|
// the buyout became optional leaves behind, and those listings all had one.
|
|
func (l Listing) HasBuyout() bool {
|
|
return l.BuyoutPrice != -1
|
|
}
|
|
|
|
// BuyoutUnitPrice is the per-item 一口价, or 0 when the listing has none.
|
|
func (l Listing) BuyoutUnitPrice() int64 {
|
|
if !l.HasBuyout() {
|
|
return 0
|
|
}
|
|
if l.BuyoutPrice > 0 {
|
|
return l.BuyoutPrice
|
|
}
|
|
return l.UnitPrice
|
|
}
|
|
|
|
// BidUnitPrice is the per-item price the client shows as the current bid. With
|
|
// no bid ledger on this server it is the seller's opening bid, and the buyout
|
|
// for a listing registered buyout-only. There is deliberately no top-bid term:
|
|
// Buy refuses every payment below the buyout total, so no bid is ever recorded
|
|
// and a top-bid field would only ever hold zero.
|
|
//
|
|
// Any StartPrice at or below zero means buyout-only, not "an opening bid of
|
|
// zero". That covers the -1 the client sends, and it keeps the struct's zero
|
|
// value - what every caller written before StartPrice existed leaves behind -
|
|
// from quietly turning into a free item on the current-bid control.
|
|
func (l Listing) BidUnitPrice() int64 {
|
|
if l.StartPrice > 0 {
|
|
return l.StartPrice
|
|
}
|
|
return l.UnitPrice
|
|
}
|
|
|
|
type RegisterCommand struct {
|
|
ListingID uint64
|
|
SellerID string
|
|
AccountID string
|
|
SellerName string
|
|
ListType byte
|
|
SourceSlot int16
|
|
ExpectedID int64
|
|
Count int64
|
|
UnitPrice int64
|
|
// SourceAccountOwned marks a listing whose stack lives in the account-scoped
|
|
// crystal/soul warehouse. The current client addresses those entries as
|
|
// ordinary list-0 slots 354..365 but marks the container as 36 on op183, and
|
|
// the durable owner of that range is the account, not the character, so the
|
|
// debit has to run against the account inventory. Everything after the
|
|
// debit is unchanged: the consignment rides the same escrow row, and both
|
|
// the buyer's delivery mail and the seller's cancel/expiry mail already
|
|
// route crystal and soul attachments back into the account warehouse
|
|
// (mailboxAttachmentUsesAccountInventory).
|
|
SourceAccountOwned bool
|
|
// StartPrice is the per-item opening bid, or -1 for buyout-only. Anything
|
|
// else must fall in [0, UnitPrice]: the client's own op183 encoder cannot
|
|
// produce an opening bid above the buyout.
|
|
StartPrice int64
|
|
// BuyoutPrice is the per-item 一口价, or -1 for a listing registered with
|
|
// an opening bid and no buyout. Zero means "unset" and is read as a buyout
|
|
// equal to UnitPrice, which is what every caller written before the buyout
|
|
// became optional intends.
|
|
BuyoutPrice int64
|
|
// Deposit is the refundable gold to take from the seller now, and
|
|
// FeePercent the commission to freeze into the listing. Both default to
|
|
// zero so a caller that predates auction charges registers a free listing
|
|
// rather than silently taking gold.
|
|
Deposit int64
|
|
FeePercent int64
|
|
Duration time.Duration
|
|
ListingLimit int
|
|
// ExpectedBind freezes the source row's seal/bind byte between the bridge
|
|
// preview and the transactional escrow move. AllowSealed is set only for
|
|
// PVF equipment whose rarity requires a sealed row; ordinary stackables and
|
|
// unsealed common/uncommon equipment keep both values false.
|
|
ExpectedBind bool
|
|
AllowSealed bool
|
|
}
|
|
|
|
type BuyCommand struct {
|
|
BuyerID string
|
|
AccountID string
|
|
ListingID uint64
|
|
ExpectedItemID int64
|
|
Count int64
|
|
Money int64
|
|
WholeListing bool
|
|
}
|
|
|
|
type CancelCommand struct {
|
|
SellerID string
|
|
AccountID string
|
|
ListingID uint64
|
|
}
|
|
|
|
// BidCommand is one 竞拍 on the bidding opcode: Money is the whole-listing
|
|
// amount the bidder typed, not a per-item price.
|
|
type BidCommand struct {
|
|
BidderID string
|
|
AccountID string
|
|
ListingID uint64
|
|
ExpectedItemID int64
|
|
Money int64
|
|
}
|
|
|
|
type Result struct {
|
|
Listing Listing
|
|
Inventory dnfrepo.InventoryRecord
|
|
Character dnfrepo.CharacterRecord
|
|
Destination int16
|
|
GoldAfter int64
|
|
ClaimedGold int64
|
|
ClaimedItems []dnfrepo.ItemStack
|
|
ReturnedRows []ReturnedRow
|
|
Payout Payout
|
|
Delivery Delivery
|
|
// Refund is the released-bid row a bid, buyout or cancel wrote for a
|
|
// bidder who lost their standing bid. Like Payout it is gold in the escrow
|
|
// wallet waiting on an idempotent mail, so the caller has to send it.
|
|
Refund Payout
|
|
// Settlements are the won-listing rows an expiry sweep produced: one
|
|
// delivery to the winning bidder plus the seller's proceeds, per listing.
|
|
Settlements []Settlement
|
|
// ExpiredReturns are the outbox rows written for listings that ran out
|
|
// their duration. Like a cancel, an expired consignment leaves by system
|
|
// mail, so the caller has to send each one and confirm it.
|
|
ExpiredReturns []Delivery
|
|
}
|
|
|
|
// Payout is the durable gold outbox. SellerID is the recipient rather than
|
|
// strictly the seller: a refunded bid rides the same rows so that a bidder who
|
|
// was outbid is paid back through the same idempotent mail path a sale uses,
|
|
// with Refund telling the bridge which wording to send.
|
|
type Payout struct {
|
|
ID string
|
|
ListingID uint64
|
|
SellerID string
|
|
SellerName string
|
|
ItemID int64
|
|
SoldCount int64
|
|
Gold int64
|
|
Refund bool
|
|
// Statement is the seller's 成交 breakdown. HasStatement is false for a
|
|
// refunded bid - announced with its own wording - and for a sale row written
|
|
// before the breakdown was persisted, which keeps the older one-line letter.
|
|
Statement SaleStatement
|
|
HasStatement bool
|
|
}
|
|
|
|
type ConfirmPayoutCommand struct {
|
|
PayoutID string
|
|
SellerID string
|
|
Gold int64
|
|
}
|
|
|
|
// Delivery is the durable buyer-item outbox. The item stays in auction escrow
|
|
// until the idempotent system-mail write has committed and ConfirmDeliveryMail
|
|
// removes this row.
|
|
type Delivery struct {
|
|
ID string
|
|
ListingID uint64
|
|
BuyerID string
|
|
Item dnfrepo.ItemStack
|
|
// Gold rides along with a returned consignment: the registration deposit,
|
|
// handed back in the same mail as the item.
|
|
Gold int64
|
|
// Return marks a consignment going back to its seller rather than a
|
|
// purchase going to its buyer, and Expired distinguishes a listing that
|
|
// ran out its duration from one the seller cancelled. Every kind rides the
|
|
// same outbox and a deferred row is re-sent from the key alone, so the
|
|
// kind has to survive in the key - see deliveryKeyForReturn/ForExpiry.
|
|
Return bool
|
|
Expired bool
|
|
}
|
|
|
|
type ConfirmDeliveryCommand struct {
|
|
DeliveryID string
|
|
BuyerID string
|
|
ListingID uint64
|
|
ItemID int64
|
|
Count int64
|
|
// Gold is the deposit refund the mail carried. It leaves the escrow
|
|
// wallet here, not when the outbox row was written, so a deferred mail
|
|
// never double-spends it.
|
|
Gold int64
|
|
}
|
|
|
|
type ReturnedRow struct {
|
|
ListType byte
|
|
Slot int16
|
|
Stack dnfrepo.ItemStack
|
|
}
|
|
|
|
type Owner struct {
|
|
repositories dnfrepo.Group
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
|
if repositories.CharacterTrade == nil || repositories.Character == nil || repositories.Inventory == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
return &Owner{repositories: repositories, now: time.Now}, nil
|
|
}
|
|
|
|
func (o *Owner) EnsureEscrow(ctx context.Context) error {
|
|
if o == nil {
|
|
return ErrUnavailable
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
now := o.clock()
|
|
character, found, err := o.repositories.Character.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
character = dnfrepo.CharacterRecord{CharacterID: EscrowCharacterID, AccountID: EscrowAccountID, Slot: -1, Name: "Auction Escrow", Stats: map[string]int64{"gold": 0}, CreatedAt: now}
|
|
} else if strings.TrimSpace(character.AccountID) != EscrowAccountID {
|
|
return ErrUnavailable
|
|
}
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
character.UpdatedAt = now
|
|
if err := o.repositories.Character.Save(ctx, character); err != nil {
|
|
return err
|
|
}
|
|
inventory, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
inventory = dnfrepo.InventoryRecord{CharacterID: EscrowCharacterID, Slots: make(map[string]dnfrepo.ItemStack)}
|
|
}
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
inventory.UpdatedAt = now
|
|
return o.repositories.Inventory.Save(ctx, inventory)
|
|
}
|
|
|
|
func (o *Owner) Register(ctx context.Context, cmd RegisterCommand) (Result, error) {
|
|
cmd.SellerID = strings.TrimSpace(cmd.SellerID)
|
|
cmd.AccountID = strings.TrimSpace(cmd.AccountID)
|
|
if o == nil || cmd.ListingID == 0 || cmd.SellerID == "" || cmd.AccountID == "" || cmd.SellerID == EscrowCharacterID || cmd.SourceSlot < 0 || (cmd.ListType != 0 && cmd.ListType != 1) || cmd.ExpectedID <= 0 || cmd.Count <= 0 || cmd.UnitPrice <= 0 || cmd.Duration <= 0 || cmd.ListingLimit <= 0 {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if cmd.StartPrice < -1 || cmd.StartPrice > cmd.UnitPrice {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
buyoutPrice := cmd.BuyoutPrice
|
|
if buyoutPrice == 0 {
|
|
buyoutPrice = cmd.UnitPrice
|
|
}
|
|
// UnitPrice is the asking price either way: the buyout when there is one,
|
|
// and the opening bid when the seller set no 一口价. Anything else would
|
|
// let a listing sell at a price neither control on the client shows.
|
|
if buyoutPrice == -1 {
|
|
if cmd.StartPrice != cmd.UnitPrice {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
} else if buyoutPrice != cmd.UnitPrice {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if cmd.Deposit < 0 || cmd.FeePercent < 0 || cmd.FeePercent > 100 {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
// The account warehouse only exists inside an account-aware transaction, so
|
|
// the two source containers take different transaction entry points and
|
|
// otherwise share one body. The character path is byte-for-byte the trade
|
|
// it has always been.
|
|
var err error
|
|
if cmd.SourceAccountOwned {
|
|
err = o.repositories.AccountCharacterTrade.WithinAccountCharacterTrade(
|
|
ctx, cmd.AccountID, cmd.SellerID, EscrowCharacterID,
|
|
func(
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EpicBookRepository,
|
|
_ dnfrepo.PetRepository,
|
|
) error {
|
|
return o.registerWithin(ctx, cmd, characters, inventories, accountInventories, &result)
|
|
})
|
|
} else {
|
|
err = o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, cmd.SellerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
return o.registerWithin(ctx, cmd, characters, inventories, nil, &result)
|
|
})
|
|
}
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// currentAuctionSourceInventory loads the durable inventory a registration
|
|
// draws its stack from. Container 36 is the account-scoped crystal/soul
|
|
// warehouse, whose wire slots are still list-0 354..365.
|
|
func currentAuctionSourceInventory(
|
|
ctx context.Context,
|
|
cmd RegisterCommand,
|
|
inventories dnfrepo.InventoryRepository,
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
) (dnfrepo.InventoryRecord, bool, error) {
|
|
if !cmd.SourceAccountOwned {
|
|
return inventories.Load(ctx, cmd.SellerID)
|
|
}
|
|
if accountInventories == nil {
|
|
return dnfrepo.InventoryRecord{}, false, nil
|
|
}
|
|
account, found, err := accountInventories.Load(ctx, cmd.AccountID)
|
|
if err != nil {
|
|
return dnfrepo.InventoryRecord{}, false, err
|
|
}
|
|
if !found {
|
|
// An account that has never stored anything in the warehouse has an
|
|
// empty warehouse rather than a missing seller.
|
|
return dnfrepo.InventoryRecord{CharacterID: cmd.AccountID}, false, nil
|
|
}
|
|
return dnfrepo.InventoryRecord{
|
|
CharacterID: cmd.AccountID,
|
|
Slots: account.Slots,
|
|
UpdatedAt: account.UpdatedAt,
|
|
}, true, nil
|
|
}
|
|
|
|
// saveAuctionSourceInventory writes the post-debit stack back to the container
|
|
// it came from, so a warehouse listing never falls back into a character's own
|
|
// list 0 - which would leave the same stack visible twice.
|
|
func saveAuctionSourceInventory(
|
|
ctx context.Context,
|
|
cmd RegisterCommand,
|
|
inventories dnfrepo.InventoryRepository,
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
record dnfrepo.InventoryRecord,
|
|
) error {
|
|
if !cmd.SourceAccountOwned {
|
|
return inventories.Save(ctx, record)
|
|
}
|
|
if accountInventories == nil {
|
|
return ErrUnavailable
|
|
}
|
|
if record.Slots == nil {
|
|
record.Slots = map[string]dnfrepo.ItemStack{}
|
|
}
|
|
return accountInventories.Save(ctx, dnfrepo.AccountInventoryRecord{
|
|
AccountID: cmd.AccountID,
|
|
Slots: record.Slots,
|
|
UpdatedAt: record.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// registerWithin is the transaction body of Register. accountInventories is nil
|
|
// on the character-inventory path.
|
|
func (o *Owner) registerWithin(
|
|
ctx context.Context,
|
|
cmd RegisterCommand,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
accountInventories dnfrepo.AccountInventoryRepository,
|
|
result *Result,
|
|
) error {
|
|
// Same derivation Register validated, repeated here because the body no
|
|
// longer shares its scope.
|
|
buyoutPrice := cmd.BuyoutPrice
|
|
if buyoutPrice == 0 {
|
|
buyoutPrice = cmd.UnitPrice
|
|
}
|
|
seller, found, err := characters.Load(ctx, cmd.SellerID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if strings.TrimSpace(seller.AccountID) != cmd.AccountID {
|
|
return ErrInvalidRequest
|
|
}
|
|
sellerInventory, found, err := currentAuctionSourceInventory(ctx, cmd, inventories, accountInventories)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrItemUnavailable, err)
|
|
}
|
|
escrow, _, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if escrow.Slots == nil {
|
|
escrow.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
if _, exists := escrow.Slots[listingKey(cmd.ListingID)]; exists {
|
|
return ErrListingIDExists
|
|
}
|
|
now := o.clock()
|
|
active := 0
|
|
for key, stack := range escrow.Slots {
|
|
if !strings.HasPrefix(key, listingPrefix) || stack.Extra["seller_id"] != cmd.SellerID {
|
|
continue
|
|
}
|
|
expires, parseErr := strconv.ParseInt(stack.Extra["expires_at"], 10, 64)
|
|
if parseErr == nil && expires > now.Unix() {
|
|
active++
|
|
}
|
|
}
|
|
if active >= cmd.ListingLimit {
|
|
return ErrListingLimit
|
|
}
|
|
sourceKey := inventoryKey(cmd.ListType, cmd.SourceSlot)
|
|
stack, exists := sellerInventory.Slots[sourceKey]
|
|
if !exists || stack.ItemID != cmd.ExpectedID || stack.Count < cmd.Count ||
|
|
stack.Bind != cmd.ExpectedBind || (stack.Bind && !cmd.AllowSealed) ||
|
|
(!stack.ExpireAt.IsZero() && !stack.ExpireAt.After(now)) {
|
|
return ErrItemUnavailable
|
|
}
|
|
listed := cloneStack(stack)
|
|
listed.Count = cmd.Count
|
|
listed.Extra = cloneExtra(listed.Extra)
|
|
listed.Extra["seller_id"] = cmd.SellerID
|
|
listed.Extra["seller_name"] = strings.TrimSpace(cmd.SellerName)
|
|
listed.Extra["list_type"] = strconv.Itoa(int(cmd.ListType))
|
|
listed.Extra["source_slot"] = strconv.Itoa(int(cmd.SourceSlot))
|
|
if cmd.SourceAccountOwned {
|
|
listed.Extra["source_account_owned"] = "1"
|
|
}
|
|
listed.Extra["unit_price"] = strconv.FormatInt(cmd.UnitPrice, 10)
|
|
listed.Extra["start_price"] = strconv.FormatInt(cmd.StartPrice, 10)
|
|
listed.Extra["buyout_price"] = strconv.FormatInt(buyoutPrice, 10)
|
|
listed.Extra["deposit"] = strconv.FormatInt(cmd.Deposit, 10)
|
|
listed.Extra["fee_percent"] = strconv.FormatInt(cmd.FeePercent, 10)
|
|
listed.Extra["created_at"] = strconv.FormatInt(now.Unix(), 10)
|
|
listed.Extra["expires_at"] = strconv.FormatInt(now.Add(cmd.Duration).Unix(), 10)
|
|
if stack.Count == cmd.Count {
|
|
delete(sellerInventory.Slots, sourceKey)
|
|
} else {
|
|
stack.Count -= cmd.Count
|
|
sellerInventory.Slots[sourceKey] = stack
|
|
}
|
|
escrow.Slots[listingKey(cmd.ListingID)] = listed
|
|
// The deposit moves into the escrow wallet with the item, so gold is
|
|
// conserved: it comes back out on a cancel, on expiry, or folded into
|
|
// the proceeds when the listing sells out.
|
|
if cmd.Deposit > 0 {
|
|
escrowCharacter, escrowFound, escrowErr := characters.Load(ctx, EscrowCharacterID)
|
|
if escrowErr != nil || !escrowFound {
|
|
return errors.Join(ErrUnavailable, escrowErr)
|
|
}
|
|
if seller.Stats == nil || seller.Stats["gold"] < cmd.Deposit {
|
|
return ErrGoldUnavailable
|
|
}
|
|
if escrowCharacter.Stats == nil {
|
|
escrowCharacter.Stats = make(map[string]int64)
|
|
}
|
|
if escrowCharacter.Stats["gold"] > math.MaxInt64-cmd.Deposit {
|
|
return ErrInvalidRequest
|
|
}
|
|
seller.Stats["gold"] -= cmd.Deposit
|
|
escrowCharacter.Stats["gold"] += cmd.Deposit
|
|
seller.UpdatedAt, escrowCharacter.UpdatedAt = now, now
|
|
if err := characters.Save(ctx, seller); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
sellerInventory.UpdatedAt = now
|
|
escrow.UpdatedAt = now
|
|
if err := saveAuctionSourceInventory(ctx, cmd, inventories, accountInventories, sellerInventory); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
listing, err := listingFromStack(cmd.ListingID, listed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*result = Result{
|
|
Listing: listing, Inventory: dnfrepo.CloneInventory(sellerInventory),
|
|
Character: dnfrepo.CloneCharacter(seller), GoldAfter: seller.Stats["gold"],
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *Owner) Search(ctx context.Context, itemIDs []uint32) ([]Listing, error) {
|
|
if o == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
record, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil, err
|
|
}
|
|
wanted := make(map[int64]struct{}, len(itemIDs))
|
|
for _, itemID := range itemIDs {
|
|
if itemID != 0 {
|
|
wanted[int64(itemID)] = struct{}{}
|
|
}
|
|
}
|
|
now := o.clock()
|
|
result := make([]Listing, 0)
|
|
for key, stack := range record.Slots {
|
|
if !strings.HasPrefix(key, listingPrefix) {
|
|
continue
|
|
}
|
|
if len(wanted) != 0 {
|
|
if _, ok := wanted[stack.ItemID]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
id, err := strconv.ParseUint(strings.TrimPrefix(key, listingPrefix), 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
listing, err := listingFromStack(id, stack)
|
|
if err == nil && listing.ExpiresAt.After(now) {
|
|
result = append(result, listing)
|
|
}
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].UnitPrice == result[j].UnitPrice {
|
|
return result[i].ID < result[j].ID
|
|
}
|
|
return result[i].UnitPrice < result[j].UnitPrice
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
func (o *Owner) Cancel(ctx context.Context, cmd CancelCommand) (Result, error) {
|
|
cmd.SellerID = strings.TrimSpace(cmd.SellerID)
|
|
cmd.AccountID = strings.TrimSpace(cmd.AccountID)
|
|
if o == nil || cmd.SellerID == "" || cmd.AccountID == "" || cmd.ListingID == 0 {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, cmd.SellerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, cmd.SellerID)
|
|
if err != nil || !found || strings.TrimSpace(seller.AccountID) != cmd.AccountID {
|
|
return errors.Join(ErrNotOwner, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
stack, exists := escrow.Slots[listingKey(cmd.ListingID)]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := listingFromStack(cmd.ListingID, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.SellerID != cmd.SellerID {
|
|
return ErrNotOwner
|
|
}
|
|
// A cancelled consignment goes back by system mail, the same channel a
|
|
// sale already uses for the buyer's item and the seller's gold. Writing
|
|
// it straight into the seller's bag was both a different experience
|
|
// from the game - 86JP's AuctionReturnService mails the cancelled and
|
|
// the expired listing through one shared transition - and a failure
|
|
// mode of its own: with no free slot the cancel simply errored and the
|
|
// item stayed stuck in escrow.
|
|
deliveryID := deliveryKeyForReturn(cmd.ListingID)
|
|
if _, exists := escrow.Slots[deliveryID]; exists {
|
|
return ErrInvalidRequest
|
|
}
|
|
// A cancelled listing releases whatever bid was standing on it, the
|
|
// same way 86JP's shared cancel/expire transition refunds the current
|
|
// bid before returning the item.
|
|
refund := releaseListingBid(escrow.Slots, listing)
|
|
returned := cloneStack(stack)
|
|
stripListingExtra(&returned)
|
|
deliveryStack := cloneStack(returned)
|
|
deliveryStack.Extra = cloneExtra(deliveryStack.Extra)
|
|
deliveryStack.Extra["buyer_id"] = cmd.SellerID
|
|
deliveryStack.Extra["listing_id"] = strconv.FormatUint(cmd.ListingID, 10)
|
|
// The registration deposit is FORFEITED on a manual cancel and no
|
|
// refund_gold key is written, so ConfirmDeliveryMail moves nothing out of
|
|
// the escrow wallet and the 10000 stays there as a gold sink - exactly
|
|
// like the sale commission. The client's own confirmation dialog is the
|
|
// authority: dstr 60149 reads
|
|
// 确定取消上架吗?
|
|
// 取消上架后, 信誉度会下降, 且无法获得缴纳的押金。
|
|
// 取消的拍卖品将以邮件的形式发放。
|
|
// - "you cannot get back the deposit you paid". Only an expiry (流拍)
|
|
// still refunds it, and that is the sibling path in ReturnExpiredListings.
|
|
escrow.Slots[deliveryID] = deliveryStack
|
|
delete(escrow.Slots, listingKey(cmd.ListingID))
|
|
escrow.UpdatedAt = o.clock()
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
listing.Item = returned
|
|
result = Result{
|
|
Listing: listing, Character: dnfrepo.CloneCharacter(seller), Destination: -1,
|
|
Delivery: Delivery{ID: deliveryID, ListingID: cmd.ListingID, BuyerID: cmd.SellerID, Item: cloneStack(returned), Return: true},
|
|
Refund: refund,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// LoadListing reads one live listing without locking anything. It exists for
|
|
// the bridge's read-only decisions - a bidding request has to know the
|
|
// listing's buyout total before it can tell a 竞拍 from a buyout - and must
|
|
// never be used to gate a mutation: only the transactional paths re-read the
|
|
// row under their own boundary.
|
|
func (o *Owner) LoadListing(ctx context.Context, id uint64) (Listing, error) {
|
|
if o == nil || id == 0 {
|
|
return Listing{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Listing{}, err
|
|
}
|
|
escrow, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return Listing{}, errors.Join(ErrUnavailable, err)
|
|
}
|
|
stack, exists := escrow.Slots[listingKey(id)]
|
|
if !exists {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
return listingFromStack(id, stack)
|
|
}
|
|
|
|
// Settlement is one expired listing that had a standing bid and therefore
|
|
// sold to that bidder rather than going back to its seller.
|
|
type Settlement struct {
|
|
ListingID uint64
|
|
Delivery Delivery
|
|
Payout Payout
|
|
}
|
|
|
|
// releaseListingBid queues the standing bid back to whoever placed it. The
|
|
// gold is already in the escrow wallet, so this only writes the outbox row;
|
|
// ConfirmPayoutMail is what actually moves it out, which keeps a deferred or
|
|
// retried mail from paying twice. Writing the row twice is a no-op: the key
|
|
// carries the bidder and the amount.
|
|
func releaseListingBid(slots map[string]dnfrepo.ItemStack, listing Listing) Payout {
|
|
if listing.BidTotal <= 0 || listing.BidderID == "" {
|
|
return Payout{}
|
|
}
|
|
key := refundKeyForBid(listing.ID, listing.BidderID, listing.BidTotal)
|
|
if _, exists := slots[key]; exists {
|
|
return Payout{}
|
|
}
|
|
slots[key] = dnfrepo.ItemStack{
|
|
ItemID: listing.Item.ItemID,
|
|
Count: listing.BidTotal,
|
|
Extra: map[string]string{
|
|
"seller_id": listing.BidderID,
|
|
"listing_id": strconv.FormatUint(listing.ID, 10),
|
|
"sold_count": "0",
|
|
"refund": "1",
|
|
},
|
|
}
|
|
return Payout{
|
|
ID: key, ListingID: listing.ID, SellerID: listing.BidderID,
|
|
ItemID: listing.Item.ItemID, Gold: listing.BidTotal, Refund: true,
|
|
}
|
|
}
|
|
|
|
// consumeListingBidRefunds takes back the gold this bidder already has parked
|
|
// in escrow for this listing - releases from earlier rounds that have not been
|
|
// mailed out yet - and returns the total, deleting the rows in the same
|
|
// transaction so the same gold can never be both credited here and mailed.
|
|
//
|
|
// It exists because a released bid is only *queued* for return: it leaves
|
|
// escrow when the refund mail commits, and reaches the wallet only once the
|
|
// player empties their mailbox. Without this, being outbid locked your own
|
|
// money away from the very listing you were bidding on - with 10,000 gold and
|
|
// an 8,000 bid outstanding you could not answer a 8,500 raise, even though the
|
|
// 8,000 is yours and sitting right there. Applying it is not a discount: it is
|
|
// the same escrow gold, moving from "owed back to you" to "committed to your
|
|
// new bid", which is exactly what would happen if you had already collected it
|
|
// and spent it again.
|
|
func consumeListingBidRefunds(slots map[string]dnfrepo.ItemStack, listingID uint64, bidderID string) int64 {
|
|
if len(slots) == 0 || bidderID == "" {
|
|
return 0
|
|
}
|
|
prefix := refundKeyPrefixForBid(listingID, bidderID)
|
|
credit := int64(0)
|
|
for key, stack := range slots {
|
|
if !strings.HasPrefix(key, prefix) || stack.Count <= 0 {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(stack.Extra["refund"]) != "1" ||
|
|
strings.TrimSpace(stack.Extra["seller_id"]) != bidderID {
|
|
continue
|
|
}
|
|
if credit > math.MaxInt64-stack.Count {
|
|
continue
|
|
}
|
|
credit += stack.Count
|
|
delete(slots, key)
|
|
}
|
|
return credit
|
|
}
|
|
|
|
// Bid records one 竞拍 against a listing, following 86JP's
|
|
// AuctionBidService.TryBid: the amount has to beat the standing bid (or reach
|
|
// the opening bid when there is none) and stay under the buyout, the bidder's
|
|
// gold moves into escrow immediately, and whoever held the previous bid gets
|
|
// it back by mail. A bidder raising their own bid only pays the difference,
|
|
// because the earlier amount is already escrowed under their name.
|
|
func (o *Owner) Bid(ctx context.Context, cmd BidCommand) (Result, error) {
|
|
cmd.BidderID = strings.TrimSpace(cmd.BidderID)
|
|
cmd.AccountID = strings.TrimSpace(cmd.AccountID)
|
|
if o == nil || cmd.BidderID == "" || cmd.AccountID == "" || cmd.BidderID == EscrowCharacterID ||
|
|
cmd.ListingID == 0 || cmd.ExpectedItemID <= 0 || cmd.Money <= 0 {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, cmd.BidderID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
bidder, found, err := characters.Load(ctx, cmd.BidderID)
|
|
if err != nil || !found || strings.TrimSpace(bidder.AccountID) != cmd.AccountID {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if bidder.Stats == nil {
|
|
bidder.Stats = make(map[string]int64)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
if escrowCharacter.Stats == nil {
|
|
escrowCharacter.Stats = make(map[string]int64)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
stack, exists := escrow.Slots[listingKey(cmd.ListingID)]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := listingFromStack(cmd.ListingID, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !listing.ExpiresAt.After(o.clock()) {
|
|
return ErrListingExpired
|
|
}
|
|
if listing.Item.ItemID != cmd.ExpectedItemID {
|
|
return ErrItemUnavailable
|
|
}
|
|
if listing.SellerID == cmd.BidderID {
|
|
return ErrBuyerIsSeller
|
|
}
|
|
if !listing.HasBidding() {
|
|
return ErrBiddingNotAllowed
|
|
}
|
|
// Three refusals, all "your number does not take this listing": at or
|
|
// below the standing bid, under the opening bid, or at/above the
|
|
// buyout - the last one is a buyout, not a bid.
|
|
switch {
|
|
case listing.BidTotal > 0 && cmd.Money <= listing.BidTotal:
|
|
return ErrBidTooLow
|
|
case listing.BidTotal == 0 && cmd.Money < listing.OpeningBidTotal():
|
|
return ErrBidTooLow
|
|
case listing.HasBuyout() && cmd.Money >= listing.BuyoutTotal():
|
|
return ErrBidTooLow
|
|
}
|
|
sameBidder := listing.BidTotal > 0 && listing.BidderID == cmd.BidderID
|
|
var refund Payout
|
|
if !sameBidder {
|
|
refund = releaseListingBid(escrow.Slots, listing)
|
|
}
|
|
// Everything of this bidder's that is already escrowed against this
|
|
// listing counts towards the new bid: the standing bid when they are
|
|
// raising their own, plus any earlier release still waiting on its
|
|
// mail. Only the difference leaves the wallet.
|
|
credit := consumeListingBidRefunds(escrow.Slots, cmd.ListingID, cmd.BidderID)
|
|
if sameBidder {
|
|
credit += listing.BidTotal
|
|
}
|
|
required := cmd.Money - credit
|
|
// A bid always beats the standing one, which is at least as large as
|
|
// anything this bidder had escrowed here, so the credit can never
|
|
// cover the whole bid; a negative charge would mean the ledger is
|
|
// inconsistent rather than that the bid is free.
|
|
if required < 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
if bidder.Stats["gold"] < required {
|
|
return ErrGoldUnavailable
|
|
}
|
|
if escrowCharacter.Stats["gold"] > math.MaxInt64-required {
|
|
return ErrInvalidRequest
|
|
}
|
|
bidder.Stats["gold"] -= required
|
|
escrowCharacter.Stats["gold"] += required
|
|
now := o.clock()
|
|
writeListingBid(&stack, cmd.BidderID, cmd.AccountID, cmd.Money, now)
|
|
escrow.Slots[listingKey(cmd.ListingID)] = stack
|
|
bidder.UpdatedAt, escrowCharacter.UpdatedAt, escrow.UpdatedAt = now, now, now
|
|
if err := characters.Save(ctx, bidder); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
listing.BidTotal, listing.BidderID, listing.BidderAccountID, listing.BidAt = cmd.Money, cmd.BidderID, cmd.AccountID, now
|
|
result = Result{
|
|
Listing: listing, Character: dnfrepo.CloneCharacter(bidder),
|
|
Destination: -1, GoldAfter: bidder.Stats["gold"], Refund: refund,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (o *Owner) Buy(ctx context.Context, cmd BuyCommand) (Result, error) {
|
|
cmd.BuyerID = strings.TrimSpace(cmd.BuyerID)
|
|
cmd.AccountID = strings.TrimSpace(cmd.AccountID)
|
|
if o == nil || cmd.BuyerID == "" || cmd.AccountID == "" || cmd.BuyerID == EscrowCharacterID || cmd.ListingID == 0 || cmd.ExpectedItemID <= 0 || (!cmd.WholeListing && cmd.Count <= 0) || cmd.Money <= 0 {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, cmd.BuyerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
buyer, found, err := characters.Load(ctx, cmd.BuyerID)
|
|
if err != nil || !found || strings.TrimSpace(buyer.AccountID) != cmd.AccountID {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if buyer.Stats == nil {
|
|
buyer.Stats = make(map[string]int64)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
if escrowCharacter.Stats == nil {
|
|
escrowCharacter.Stats = make(map[string]int64)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
stack, exists := escrow.Slots[listingKey(cmd.ListingID)]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := listingFromStack(cmd.ListingID, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !listing.ExpiresAt.After(o.clock()) {
|
|
return ErrListingExpired
|
|
}
|
|
if listing.Item.ItemID != cmd.ExpectedItemID {
|
|
return ErrItemUnavailable
|
|
}
|
|
count := cmd.Count
|
|
if cmd.WholeListing {
|
|
count = stack.Count
|
|
}
|
|
if listing.SellerID == cmd.BuyerID {
|
|
return ErrBuyerIsSeller
|
|
}
|
|
if count <= 0 || stack.Count < count || listing.UnitPrice > math.MaxInt64/count {
|
|
return ErrInvalidRequest
|
|
}
|
|
total := listing.UnitPrice * count
|
|
if cmd.Money != total {
|
|
return ErrPriceMismatch
|
|
}
|
|
// A listing under bid can only be bought out whole: leaving a partial
|
|
// row behind would strand the standing bid on an item the bidder can
|
|
// no longer win in full. 86JP's TryBuy refuses the same case with
|
|
// InvalidQuantity.
|
|
if listing.BidTotal > 0 && count != stack.Count {
|
|
return ErrInvalidRequest
|
|
}
|
|
// The buyer's own standing bid is already escrowed under their name,
|
|
// so it counts towards the buyout and only the difference is charged -
|
|
// the same netting 86JP does with buyerOwnsCurrentBid - and so does
|
|
// any earlier release of theirs on this listing that has not been
|
|
// mailed out yet, for the reason consumeListingBidRefunds explains.
|
|
buyerHoldsBid := listing.BidTotal > 0 && listing.BidderID == cmd.BuyerID
|
|
var refund Payout
|
|
if listing.BidTotal > 0 && !buyerHoldsBid {
|
|
refund = releaseListingBid(escrow.Slots, listing)
|
|
}
|
|
credit := consumeListingBidRefunds(escrow.Slots, cmd.ListingID, cmd.BuyerID)
|
|
if buyerHoldsBid {
|
|
credit += listing.BidTotal
|
|
}
|
|
if credit > total {
|
|
return ErrInvalidRequest
|
|
}
|
|
charged := total - credit
|
|
if buyer.Stats["gold"] < charged {
|
|
return ErrGoldUnavailable
|
|
}
|
|
purchased := cloneStack(stack)
|
|
purchased.Count = count
|
|
stripListingExtra(&purchased)
|
|
remaining := stack.Count - count
|
|
if remaining == 0 {
|
|
delete(escrow.Slots, listingKey(cmd.ListingID))
|
|
} else {
|
|
stack.Count = remaining
|
|
// A partial buy can only happen on a listing with no standing bid
|
|
// (refused above), so there is no bid state left to carry - clear
|
|
// it anyway so a row can never keep a stale bidder.
|
|
clearListingBid(&stack)
|
|
escrow.Slots[listingKey(cmd.ListingID)] = stack
|
|
}
|
|
// Only the difference leaves the wallet when the buyer's own bid is
|
|
// being applied; the rest of the sale price is already in escrow.
|
|
buyer.Stats["gold"] -= charged
|
|
if escrowCharacter.Stats["gold"] > math.MaxInt64-charged {
|
|
return ErrInvalidRequest
|
|
}
|
|
escrowCharacter.Stats["gold"] += charged
|
|
payoutID := payoutKeyForSale(cmd.ListingID, remaining)
|
|
if _, exists := escrow.Slots[payoutID]; exists {
|
|
return ErrInvalidRequest
|
|
}
|
|
deliveryID := deliveryKeyForSale(cmd.ListingID, remaining)
|
|
if _, exists := escrow.Slots[deliveryID]; exists {
|
|
return ErrInvalidRequest
|
|
}
|
|
// The commission stays behind in the escrow wallet - it is the gold
|
|
// sink - and the registration deposit only comes back once the last
|
|
// piece is gone, so a partially bought listing keeps it riding until
|
|
// it sells out, is cancelled, or expires.
|
|
proceeds := listing.SellerProceeds(total, remaining == 0)
|
|
if proceeds < 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
statement := listing.SaleStatementFor(total, remaining == 0)
|
|
payout := dnfrepo.ItemStack{
|
|
ItemID: listing.Item.ItemID,
|
|
Count: proceeds,
|
|
Extra: map[string]string{
|
|
"seller_id": listing.SellerID,
|
|
"seller_name": listing.SellerName,
|
|
"listing_id": strconv.FormatUint(cmd.ListingID, 10),
|
|
"sold_count": strconv.FormatInt(count, 10),
|
|
"gross_gold": strconv.FormatInt(statement.GrossGold, 10),
|
|
"fee_gold": strconv.FormatInt(statement.FeeGold, 10),
|
|
"deposit_gold": strconv.FormatInt(statement.DepositGold, 10),
|
|
},
|
|
}
|
|
escrow.Slots[payoutID] = payout
|
|
deliveryStack := cloneStack(purchased)
|
|
deliveryStack.Extra = cloneExtra(deliveryStack.Extra)
|
|
deliveryStack.Extra["buyer_id"] = cmd.BuyerID
|
|
deliveryStack.Extra["listing_id"] = strconv.FormatUint(cmd.ListingID, 10)
|
|
escrow.Slots[deliveryID] = deliveryStack
|
|
now := o.clock()
|
|
buyer.UpdatedAt, escrowCharacter.UpdatedAt = now, now
|
|
escrow.UpdatedAt = now
|
|
if err := characters.Save(ctx, buyer); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
listing.Item = purchased
|
|
result = Result{
|
|
Listing: listing, Character: dnfrepo.CloneCharacter(buyer),
|
|
Destination: -1, GoldAfter: buyer.Stats["gold"],
|
|
Payout: Payout{ID: payoutID, ListingID: cmd.ListingID, SellerID: listing.SellerID, SellerName: listing.SellerName, ItemID: listing.Item.ItemID, SoldCount: count, Gold: proceeds, Statement: statement, HasStatement: true},
|
|
Delivery: Delivery{ID: deliveryID, ListingID: cmd.ListingID, BuyerID: cmd.BuyerID, Item: cloneStack(purchased)},
|
|
Refund: refund,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// PendingDeliveries returns durable buyer-item outbox rows. These rows are
|
|
// independent from the active listing and survive response/mail write errors.
|
|
func (o *Owner) PendingDeliveries(ctx context.Context, buyerID string) ([]Delivery, error) {
|
|
buyerID = strings.TrimSpace(buyerID)
|
|
if o == nil || buyerID == "" {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
escrow, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil, errors.Join(ErrUnavailable, err)
|
|
}
|
|
result := make([]Delivery, 0)
|
|
for key, stack := range escrow.Slots {
|
|
if !strings.HasPrefix(key, deliveryPrefix) || strings.TrimSpace(stack.Extra["buyer_id"]) != buyerID || stack.ItemID <= 0 || stack.Count <= 0 {
|
|
continue
|
|
}
|
|
listingID, parseErr := strconv.ParseUint(strings.TrimSpace(stack.Extra["listing_id"]), 10, 64)
|
|
if parseErr != nil || listingID == 0 {
|
|
continue
|
|
}
|
|
item := cloneStack(stack)
|
|
refund, _ := strconv.ParseInt(strings.TrimSpace(stack.Extra["refund_gold"]), 10, 64)
|
|
if refund < 0 {
|
|
refund = 0
|
|
}
|
|
delete(item.Extra, "buyer_id")
|
|
delete(item.Extra, "listing_id")
|
|
delete(item.Extra, "refund_gold")
|
|
expired := key == deliveryKeyForExpiry(listingID)
|
|
result = append(result, Delivery{
|
|
ID: key, ListingID: listingID, BuyerID: buyerID, Item: item, Gold: refund,
|
|
Return: expired || key == deliveryKeyForReturn(listingID), Expired: expired,
|
|
})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
|
return result, nil
|
|
}
|
|
|
|
// ConfirmDeliveryMail consumes one escrowed buyer item only after its matching
|
|
// idempotent system mail exists.
|
|
func (o *Owner) ConfirmDeliveryMail(ctx context.Context, cmd ConfirmDeliveryCommand) error {
|
|
cmd.DeliveryID, cmd.BuyerID = strings.TrimSpace(cmd.DeliveryID), strings.TrimSpace(cmd.BuyerID)
|
|
if o == nil || !strings.HasPrefix(cmd.DeliveryID, deliveryPrefix) || cmd.BuyerID == "" || cmd.ListingID == 0 || cmd.ItemID <= 0 || cmd.Count <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return err
|
|
}
|
|
return o.repositories.CharacterTrade.WithinCharacterTrade(ctx, cmd.BuyerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
stack, exists := escrow.Slots[cmd.DeliveryID]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
listingID, parseErr := strconv.ParseUint(strings.TrimSpace(stack.Extra["listing_id"]), 10, 64)
|
|
if parseErr != nil || listingID != cmd.ListingID || strings.TrimSpace(stack.Extra["buyer_id"]) != cmd.BuyerID || stack.ItemID != cmd.ItemID || stack.Count != cmd.Count {
|
|
return ErrInvalidRequest
|
|
}
|
|
refund, _ := strconv.ParseInt(strings.TrimSpace(stack.Extra["refund_gold"]), 10, 64)
|
|
if refund < 0 || cmd.Gold != refund {
|
|
return ErrInvalidRequest
|
|
}
|
|
now := o.clock()
|
|
// The deposit refund leaves the escrow wallet only now, together with
|
|
// the outbox row, so the mail and the accounting commit or fail as one.
|
|
if refund > 0 {
|
|
escrowCharacter, escrowFound, escrowErr := characters.Load(ctx, EscrowCharacterID)
|
|
if escrowErr != nil || !escrowFound {
|
|
return errors.Join(ErrUnavailable, escrowErr)
|
|
}
|
|
if escrowCharacter.Stats == nil || escrowCharacter.Stats["gold"] < refund {
|
|
return ErrGoldUnavailable
|
|
}
|
|
escrowCharacter.Stats["gold"] -= refund
|
|
escrowCharacter.UpdatedAt = now
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delete(escrow.Slots, cmd.DeliveryID)
|
|
escrow.UpdatedAt = now
|
|
return inventories.Save(ctx, escrow)
|
|
})
|
|
}
|
|
|
|
// PendingPayouts returns the durable auction-sale outbox rows. Gold remains
|
|
// owned by the escrow character until the matching mailbox delivery has been
|
|
// committed and ConfirmPayoutMail removes the row.
|
|
func (o *Owner) PendingPayouts(ctx context.Context, sellerID string) ([]Payout, error) {
|
|
sellerID = strings.TrimSpace(sellerID)
|
|
if o == nil || sellerID == "" {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
escrow, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil, errors.Join(ErrUnavailable, err)
|
|
}
|
|
result := make([]Payout, 0)
|
|
for key, stack := range escrow.Slots {
|
|
if !strings.HasPrefix(key, payoutPrefix) || strings.TrimSpace(stack.Extra["seller_id"]) != sellerID || stack.Count <= 0 {
|
|
continue
|
|
}
|
|
listingID, parseErr := strconv.ParseUint(strings.TrimSpace(stack.Extra["listing_id"]), 10, 64)
|
|
if parseErr != nil || listingID == 0 {
|
|
continue
|
|
}
|
|
soldCount, _ := strconv.ParseInt(strings.TrimSpace(stack.Extra["sold_count"]), 10, 64)
|
|
payout := Payout{
|
|
ID: key, ListingID: listingID, SellerID: sellerID, SellerName: strings.TrimSpace(stack.Extra["seller_name"]),
|
|
ItemID: stack.ItemID, SoldCount: soldCount, Gold: stack.Count,
|
|
Refund: strings.TrimSpace(stack.Extra["refund"]) == "1",
|
|
}
|
|
// The seller's 成交 breakdown rides the row, so a deferred retry prints
|
|
// the same letter after the listing row itself is long gone. Rows written
|
|
// before the breakdown existed keep the older one-line wording.
|
|
if raw, present := stack.Extra["gross_gold"]; present {
|
|
gross, grossErr := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
fee, feeErr := strconv.ParseInt(strings.TrimSpace(stack.Extra["fee_gold"]), 10, 64)
|
|
deposit, depositErr := strconv.ParseInt(strings.TrimSpace(stack.Extra["deposit_gold"]), 10, 64)
|
|
if grossErr == nil && feeErr == nil && depositErr == nil && gross >= 0 && fee >= 0 && deposit >= 0 {
|
|
payout.Statement = SaleStatement{
|
|
GrossGold: gross,
|
|
FeeGold: fee,
|
|
DepositGold: deposit,
|
|
NetGold: stack.Count,
|
|
}
|
|
payout.HasStatement = true
|
|
}
|
|
}
|
|
result = append(result, payout)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
|
return result, nil
|
|
}
|
|
|
|
// ConfirmPayoutMail consumes one escrow payout only after the idempotent
|
|
// mailbox delivery exists. A failed or outcome-unknown mail write leaves this
|
|
// row intact so the bridge can safely retry the same completion key.
|
|
func (o *Owner) ConfirmPayoutMail(ctx context.Context, cmd ConfirmPayoutCommand) error {
|
|
cmd.PayoutID, cmd.SellerID = strings.TrimSpace(cmd.PayoutID), strings.TrimSpace(cmd.SellerID)
|
|
if o == nil || !strings.HasPrefix(cmd.PayoutID, payoutPrefix) || cmd.SellerID == "" || cmd.Gold <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return err
|
|
}
|
|
return o.repositories.CharacterTrade.WithinCharacterTrade(ctx, cmd.SellerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
payout, exists := escrow.Slots[cmd.PayoutID]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(payout.Extra["seller_id"]) != cmd.SellerID || payout.Count != cmd.Gold ||
|
|
escrowCharacter.Stats == nil || escrowCharacter.Stats["gold"] < cmd.Gold {
|
|
return ErrInvalidRequest
|
|
}
|
|
delete(escrow.Slots, cmd.PayoutID)
|
|
escrowCharacter.Stats["gold"] -= cmd.Gold
|
|
now := o.clock()
|
|
escrowCharacter.UpdatedAt, escrow.UpdatedAt = now, now
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
return inventories.Save(ctx, escrow)
|
|
})
|
|
}
|
|
|
|
// RefundBidToBidder pays a released bid straight back into the bidder's wallet
|
|
// instead of mailing it, and returns their balance afterwards.
|
|
//
|
|
// Sale proceeds keep going by mail - that is the authentic channel and nobody
|
|
// is waiting on them. A released bid is different: the current client refuses
|
|
// to *send* a bid larger than the wallet balance at all (sub_2FFB0C0 compares
|
|
// the amount against the wallet and answers 所持金币不足%d金币 without touching
|
|
// the socket), so an outbid player whose gold is sitting in a mail cannot
|
|
// answer the raise until they walk to the mailbox and collect it. Returning it
|
|
// to the wallet is what makes being outbid recoverable in the same breath.
|
|
//
|
|
// The row is deleted in the same transaction that credits the wallet, so a
|
|
// retry after a partial failure cannot pay twice.
|
|
func (o *Owner) RefundBidToBidder(ctx context.Context, payout Payout) (int64, error) {
|
|
payout.ID, payout.SellerID = strings.TrimSpace(payout.ID), strings.TrimSpace(payout.SellerID)
|
|
if o == nil || !payout.Refund || !strings.HasPrefix(payout.ID, payoutPrefix) ||
|
|
payout.SellerID == "" || payout.SellerID == EscrowCharacterID || payout.Gold <= 0 {
|
|
return 0, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
var goldAfter int64
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, payout.SellerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
bidder, found, err := characters.Load(ctx, payout.SellerID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if bidder.Stats == nil {
|
|
bidder.Stats = make(map[string]int64)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
row, exists := escrow.Slots[payout.ID]
|
|
if !exists {
|
|
// Already consumed - by a re-bid that netted it, or by an earlier
|
|
// retry. The caller's copy is stale, not wrong.
|
|
goldAfter = bidder.Stats["gold"]
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(row.Extra["refund"]) != "1" ||
|
|
strings.TrimSpace(row.Extra["seller_id"]) != payout.SellerID || row.Count != payout.Gold ||
|
|
escrowCharacter.Stats == nil || escrowCharacter.Stats["gold"] < payout.Gold ||
|
|
bidder.Stats["gold"] > math.MaxInt64-payout.Gold {
|
|
return ErrInvalidRequest
|
|
}
|
|
delete(escrow.Slots, payout.ID)
|
|
escrowCharacter.Stats["gold"] -= payout.Gold
|
|
bidder.Stats["gold"] += payout.Gold
|
|
now := o.clock()
|
|
bidder.UpdatedAt, escrowCharacter.UpdatedAt, escrow.UpdatedAt = now, now, now
|
|
if err := characters.Save(ctx, bidder); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
goldAfter = bidder.Stats["gold"]
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return goldAfter, nil
|
|
}
|
|
|
|
func (o *Owner) ClaimProceeds(ctx context.Context, sellerID, accountID string) (Result, error) {
|
|
sellerID, accountID = strings.TrimSpace(sellerID), strings.TrimSpace(accountID)
|
|
if o == nil || sellerID == "" || accountID == "" {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, sellerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, sellerID)
|
|
if err != nil || !found || strings.TrimSpace(seller.AccountID) != accountID {
|
|
return errors.Join(ErrNotOwner, err)
|
|
}
|
|
if seller.Stats == nil {
|
|
seller.Stats = make(map[string]int64)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
if escrowCharacter.Stats == nil {
|
|
escrowCharacter.Stats = make(map[string]int64)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
sellerInventory, _, err := inventories.Load(ctx, sellerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sellerInventory.Slots == nil {
|
|
sellerInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
var total int64
|
|
expiredReturns := make([]Delivery, 0)
|
|
now := o.clock()
|
|
listingKeys := make([]string, 0)
|
|
for key := range escrow.Slots {
|
|
if strings.HasPrefix(key, listingPrefix) {
|
|
listingKeys = append(listingKeys, key)
|
|
}
|
|
}
|
|
sort.Strings(listingKeys)
|
|
for _, key := range listingKeys {
|
|
stack := escrow.Slots[key]
|
|
if stack.Extra["seller_id"] != sellerID {
|
|
continue
|
|
}
|
|
id, parseErr := strconv.ParseUint(strings.TrimPrefix(key, listingPrefix), 10, 64)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
listing, parseErr := listingFromStack(id, stack)
|
|
if parseErr != nil || listing.ExpiresAt.After(now) {
|
|
continue
|
|
}
|
|
// An expired listing carrying a bid sold to that bidder and must
|
|
// not go home to its seller. SettleWonListings owns those rows.
|
|
if listing.BidTotal > 0 {
|
|
continue
|
|
}
|
|
// An expired consignment goes home the same way a cancelled one
|
|
// does: by system mail, out of the durable outbox. 86JP's
|
|
// AuctionReturnService runs TryCancel and TryExpire through one
|
|
// shared transition for exactly this reason, and a bag with no
|
|
// free slot used to abort the whole claim sweep here.
|
|
deliveryID := deliveryKeyForExpiry(id)
|
|
if _, exists := escrow.Slots[deliveryID]; exists {
|
|
continue
|
|
}
|
|
returnedStack := cloneStack(stack)
|
|
stripListingExtra(&returnedStack)
|
|
deliveryStack := cloneStack(returnedStack)
|
|
deliveryStack.Extra = cloneExtra(deliveryStack.Extra)
|
|
deliveryStack.Extra["buyer_id"] = sellerID
|
|
deliveryStack.Extra["listing_id"] = strconv.FormatUint(id, 10)
|
|
deliveryStack.Extra["refund_gold"] = strconv.FormatInt(listing.Deposit, 10)
|
|
escrow.Slots[deliveryID] = deliveryStack
|
|
delete(escrow.Slots, key)
|
|
expiredReturns = append(expiredReturns, Delivery{
|
|
ID: deliveryID, ListingID: id, BuyerID: sellerID,
|
|
Item: cloneStack(returnedStack), Gold: listing.Deposit, Return: true, Expired: true,
|
|
})
|
|
}
|
|
for key, stack := range escrow.Slots {
|
|
if strings.HasPrefix(key, payoutPrefix) && stack.Extra["seller_id"] == sellerID {
|
|
if stack.Count <= 0 || total > math.MaxInt64-stack.Count {
|
|
return ErrInvalidRequest
|
|
}
|
|
total += stack.Count
|
|
delete(escrow.Slots, key)
|
|
}
|
|
}
|
|
if total == 0 && len(expiredReturns) == 0 {
|
|
result = Result{Character: dnfrepo.CloneCharacter(seller), Inventory: dnfrepo.CloneInventory(sellerInventory), GoldAfter: seller.Stats["gold"]}
|
|
return nil
|
|
}
|
|
if escrowCharacter.Stats["gold"] < total || seller.Stats["gold"] > math.MaxInt64-total {
|
|
return ErrGoldUnavailable
|
|
}
|
|
escrowCharacter.Stats["gold"] -= total
|
|
seller.Stats["gold"] += total
|
|
seller.UpdatedAt, escrowCharacter.UpdatedAt, escrow.UpdatedAt = now, now, now
|
|
sellerInventory.UpdatedAt = now
|
|
if err := characters.Save(ctx, seller); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, sellerInventory); err != nil {
|
|
return err
|
|
}
|
|
claimedItems := make([]dnfrepo.ItemStack, len(expiredReturns))
|
|
for index := range expiredReturns {
|
|
claimedItems[index] = cloneStack(expiredReturns[index].Item)
|
|
}
|
|
result = Result{Character: dnfrepo.CloneCharacter(seller), Inventory: dnfrepo.CloneInventory(sellerInventory), GoldAfter: seller.Stats["gold"], ClaimedGold: total, ClaimedItems: claimedItems, ExpiredReturns: expiredReturns}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ReturnExpiredListings returns only expired escrow items. Sale proceeds are
|
|
// deliberately excluded because the current auction pays sellers through
|
|
// idempotent system mail instead of crediting the wallet when op188 opens.
|
|
func (o *Owner) ReturnExpiredListings(ctx context.Context, sellerID, accountID string) (Result, error) {
|
|
sellerID, accountID = strings.TrimSpace(sellerID), strings.TrimSpace(accountID)
|
|
if o == nil || sellerID == "" || accountID == "" {
|
|
return Result{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(ctx, sellerID, EscrowCharacterID, func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, sellerID)
|
|
if err != nil || !found || strings.TrimSpace(seller.AccountID) != accountID {
|
|
return errors.Join(ErrNotOwner, err)
|
|
}
|
|
sellerInventory, _, err := inventories.Load(ctx, sellerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sellerInventory.Slots == nil {
|
|
sellerInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
now := o.clock()
|
|
keys := make([]string, 0)
|
|
for key := range escrow.Slots {
|
|
if strings.HasPrefix(key, listingPrefix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
expiredReturns := make([]Delivery, 0)
|
|
for _, key := range keys {
|
|
stack := escrow.Slots[key]
|
|
if strings.TrimSpace(stack.Extra["seller_id"]) != sellerID {
|
|
continue
|
|
}
|
|
id, parseErr := strconv.ParseUint(strings.TrimPrefix(key, listingPrefix), 10, 64)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
listing, parseErr := listingFromStack(id, stack)
|
|
if parseErr != nil || listing.ExpiresAt.After(now) {
|
|
continue
|
|
}
|
|
// An expired listing carrying a bid sold to that bidder and must
|
|
// not go home to its seller. SettleWonListings owns those rows.
|
|
if listing.BidTotal > 0 {
|
|
continue
|
|
}
|
|
// Expired listings leave by system mail, exactly like a cancel:
|
|
// 86JP's AuctionReturnService runs TryCancel and TryExpire through
|
|
// one shared transition. A full inventory tab used to strand the
|
|
// item here; the outbox has no such failure mode.
|
|
deliveryID := deliveryKeyForExpiry(id)
|
|
if _, exists := escrow.Slots[deliveryID]; exists {
|
|
continue
|
|
}
|
|
returnedStack := cloneStack(stack)
|
|
stripListingExtra(&returnedStack)
|
|
deliveryStack := cloneStack(returnedStack)
|
|
deliveryStack.Extra = cloneExtra(deliveryStack.Extra)
|
|
deliveryStack.Extra["buyer_id"] = sellerID
|
|
deliveryStack.Extra["listing_id"] = strconv.FormatUint(id, 10)
|
|
deliveryStack.Extra["refund_gold"] = strconv.FormatInt(listing.Deposit, 10)
|
|
escrow.Slots[deliveryID] = deliveryStack
|
|
delete(escrow.Slots, key)
|
|
expiredReturns = append(expiredReturns, Delivery{
|
|
ID: deliveryID, ListingID: id, BuyerID: sellerID,
|
|
Item: cloneStack(returnedStack), Gold: listing.Deposit, Return: true, Expired: true,
|
|
})
|
|
}
|
|
if len(expiredReturns) != 0 {
|
|
escrow.UpdatedAt = now
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
items := make([]dnfrepo.ItemStack, len(expiredReturns))
|
|
for index := range expiredReturns {
|
|
items[index] = cloneStack(expiredReturns[index].Item)
|
|
}
|
|
result = Result{Character: dnfrepo.CloneCharacter(seller), Inventory: dnfrepo.CloneInventory(sellerInventory), ClaimedItems: items, ExpiredReturns: expiredReturns, GoldAfter: seller.Stats["gold"]}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// SettleWonListings closes every listing whose duration ran out while a bid
|
|
// was standing on it: the item goes to that bidder and the proceeds to the
|
|
// seller. It is deliberately seller-agnostic and escrow-only - the winner's
|
|
// gold left their wallet when they bid, and the seller is paid through the
|
|
// payout outbox - so it needs neither party present, which is what lets the
|
|
// sweep run off any auction request instead of a scheduler. 86JP drives the
|
|
// same transition from AuctionExpirationScanner + AuctionReturnService.TryExpire.
|
|
//
|
|
// A listing with no bid is left alone: those go back to their seller through
|
|
// ReturnExpiredListings, which is where the seller's own bag and deposit are
|
|
// already in scope.
|
|
func (o *Owner) SettleWonListings(ctx context.Context) (Result, error) {
|
|
if o == nil {
|
|
return Result{}, ErrUnavailable
|
|
}
|
|
if o.repositories.CharacterItems == nil {
|
|
return Result{}, ErrUnavailable
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return Result{}, err
|
|
}
|
|
var result Result
|
|
// One escrow-scoped item transaction is the whole boundary: no wallet
|
|
// moves here, so neither the winner nor the seller has to be locked.
|
|
err := o.repositories.CharacterItems.WithinCharacterItems(ctx, EscrowCharacterID, func(inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
now := o.clock()
|
|
keys := make([]string, 0)
|
|
for key := range escrow.Slots {
|
|
if strings.HasPrefix(key, listingPrefix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
settlements := make([]Settlement, 0)
|
|
for _, key := range keys {
|
|
stack := escrow.Slots[key]
|
|
id, parseErr := strconv.ParseUint(strings.TrimPrefix(key, listingPrefix), 10, 64)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
listing, parseErr := listingFromStack(id, stack)
|
|
if parseErr != nil || listing.ExpiresAt.After(now) || listing.BidTotal <= 0 {
|
|
continue
|
|
}
|
|
deliveryID := deliveryKeyForBidWin(id)
|
|
payoutID := payoutKeyForSale(id, 0)
|
|
if _, exists := escrow.Slots[deliveryID]; exists {
|
|
continue
|
|
}
|
|
if _, exists := escrow.Slots[payoutID]; exists {
|
|
continue
|
|
}
|
|
// The winning bid is the sale price, so the commission comes off
|
|
// it and the deposit rides home with the proceeds - the listing is
|
|
// fully consumed either way.
|
|
proceeds := listing.SellerProceeds(listing.BidTotal, true)
|
|
if proceeds < 0 {
|
|
continue
|
|
}
|
|
settled := listing.SaleStatementFor(listing.BidTotal, true)
|
|
won := cloneStack(stack)
|
|
stripListingExtra(&won)
|
|
deliveryStack := cloneStack(won)
|
|
deliveryStack.Extra = cloneExtra(deliveryStack.Extra)
|
|
deliveryStack.Extra["buyer_id"] = listing.BidderID
|
|
deliveryStack.Extra["listing_id"] = strconv.FormatUint(id, 10)
|
|
escrow.Slots[deliveryID] = deliveryStack
|
|
escrow.Slots[payoutID] = dnfrepo.ItemStack{
|
|
ItemID: listing.Item.ItemID,
|
|
Count: proceeds,
|
|
Extra: map[string]string{
|
|
"seller_id": listing.SellerID,
|
|
"seller_name": listing.SellerName,
|
|
"listing_id": strconv.FormatUint(id, 10),
|
|
"sold_count": strconv.FormatInt(won.Count, 10),
|
|
"gross_gold": strconv.FormatInt(settled.GrossGold, 10),
|
|
"fee_gold": strconv.FormatInt(settled.FeeGold, 10),
|
|
"deposit_gold": strconv.FormatInt(settled.DepositGold, 10),
|
|
},
|
|
}
|
|
delete(escrow.Slots, key)
|
|
settlements = append(settlements, Settlement{
|
|
ListingID: id,
|
|
Delivery: Delivery{ID: deliveryID, ListingID: id, BuyerID: listing.BidderID, Item: cloneStack(won)},
|
|
Payout: Payout{
|
|
ID: payoutID, ListingID: id, SellerID: listing.SellerID, SellerName: listing.SellerName,
|
|
ItemID: listing.Item.ItemID, SoldCount: won.Count, Gold: proceeds,
|
|
Statement: settled, HasStatement: true,
|
|
},
|
|
})
|
|
}
|
|
if len(settlements) != 0 {
|
|
escrow.UpdatedAt = now
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result = Result{Settlements: settlements}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func listingFromStack(id uint64, stack dnfrepo.ItemStack) (Listing, error) {
|
|
listType, err := strconv.ParseUint(stack.Extra["list_type"], 10, 8)
|
|
if err != nil {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
slot, err := strconv.ParseInt(stack.Extra["source_slot"], 10, 16)
|
|
if err != nil {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
price, err := strconv.ParseInt(stack.Extra["unit_price"], 10, 64)
|
|
if err != nil || price <= 0 {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
// A listing escrowed before start_price existed carries no such key. It was
|
|
// registered through a decoder that already required buyout-only or an
|
|
// opening bid inside [0, unit_price], so -1 restores exactly what the row
|
|
// used to project, rather than rejecting a live listing over a missing key.
|
|
startPrice := int64(-1)
|
|
if raw, present := stack.Extra["start_price"]; present {
|
|
parsed, parseErr := strconv.ParseInt(raw, 10, 64)
|
|
if parseErr != nil || parsed < -1 || parsed > price {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
startPrice = parsed
|
|
}
|
|
// A listing escrowed before the buyout became optional always had one, and
|
|
// it was always the unit price, so a missing key restores exactly what the
|
|
// row used to project instead of retiring a live listing.
|
|
buyoutPrice := price
|
|
if raw, present := stack.Extra["buyout_price"]; present {
|
|
parsed, parseErr := strconv.ParseInt(raw, 10, 64)
|
|
if parseErr != nil || (parsed != -1 && parsed != price) {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
buyoutPrice = parsed
|
|
}
|
|
// Charges arrived after these two keys, so a row without them is a
|
|
// listing registered while the auction was free. Reading it as deposit 0 /
|
|
// fee 0 settles it on the terms its seller actually accepted.
|
|
deposit, err := listingExtraInt(stack, "deposit", 0, math.MaxInt64)
|
|
if err != nil {
|
|
return Listing{}, err
|
|
}
|
|
feePercent, err := listingExtraInt(stack, "fee_percent", 0, 100)
|
|
if err != nil {
|
|
return Listing{}, err
|
|
}
|
|
created, err := strconv.ParseInt(stack.Extra["created_at"], 10, 64)
|
|
if err != nil {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
expires, err := strconv.ParseInt(stack.Extra["expires_at"], 10, 64)
|
|
if err != nil {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
// Bid state arrived last of all, so a row without these keys is simply a
|
|
// listing nobody has bid on - which is also exactly what every listing
|
|
// escrowed before bidding existed is.
|
|
bidTotal, err := listingExtraInt(stack, "bid_total", 0, math.MaxInt64)
|
|
if err != nil {
|
|
return Listing{}, err
|
|
}
|
|
bidAt, err := listingExtraInt(stack, "bid_at", 0, math.MaxInt64)
|
|
if err != nil {
|
|
return Listing{}, err
|
|
}
|
|
bidderID := strings.TrimSpace(stack.Extra["bidder_id"])
|
|
bidderAccountID := strings.TrimSpace(stack.Extra["bidder_account_id"])
|
|
// A half-written bid would let the gold in escrow belong to nobody, so it
|
|
// retires the row instead of being read as "no bid".
|
|
if (bidTotal > 0) != (bidderID != "" && bidderAccountID != "") {
|
|
return Listing{}, ErrListingMissing
|
|
}
|
|
item := cloneStack(stack)
|
|
stripListingExtra(&item)
|
|
listing := Listing{
|
|
ID: id, SellerID: stack.Extra["seller_id"], SellerName: stack.Extra["seller_name"],
|
|
Item: item, ListType: byte(listType), SourceSlot: int16(slot),
|
|
UnitPrice: price, StartPrice: startPrice, BuyoutPrice: buyoutPrice,
|
|
Deposit: deposit, FeePercent: feePercent,
|
|
BidTotal: bidTotal, BidderID: bidderID, BidderAccountID: bidderAccountID,
|
|
CreatedAt: time.Unix(created, 0).UTC(), ExpiresAt: time.Unix(expires, 0).UTC(),
|
|
}
|
|
if bidAt > 0 {
|
|
listing.BidAt = time.Unix(bidAt, 0).UTC()
|
|
}
|
|
return listing, nil
|
|
}
|
|
|
|
// writeListingBid stores the standing bid on the escrowed listing row, and
|
|
// clearListingBid removes it. Both operate on the row's Extra because the
|
|
// listing has no record of its own: the escrow stack *is* the listing.
|
|
func writeListingBid(stack *dnfrepo.ItemStack, bidderID, bidderAccountID string, total int64, at time.Time) {
|
|
if stack == nil {
|
|
return
|
|
}
|
|
stack.Extra = cloneExtra(stack.Extra)
|
|
stack.Extra["bid_total"] = strconv.FormatInt(total, 10)
|
|
stack.Extra["bidder_id"] = bidderID
|
|
stack.Extra["bidder_account_id"] = bidderAccountID
|
|
stack.Extra["bid_at"] = strconv.FormatInt(at.Unix(), 10)
|
|
}
|
|
|
|
func clearListingBid(stack *dnfrepo.ItemStack) {
|
|
if stack == nil {
|
|
return
|
|
}
|
|
stack.Extra = cloneExtra(stack.Extra)
|
|
for _, key := range []string{"bid_total", "bidder_id", "bidder_account_id", "bid_at"} {
|
|
delete(stack.Extra, key)
|
|
}
|
|
}
|
|
|
|
func stripListingExtra(stack *dnfrepo.ItemStack) {
|
|
if stack == nil {
|
|
return
|
|
}
|
|
stack.Extra = cloneExtra(stack.Extra)
|
|
for _, key := range []string{
|
|
"seller_id", "seller_name", "list_type", "source_slot", "unit_price",
|
|
"start_price", "buyout_price", "deposit", "fee_percent", "created_at", "expires_at",
|
|
"bid_total", "bidder_id", "bidder_account_id", "bid_at",
|
|
} {
|
|
delete(stack.Extra, key)
|
|
}
|
|
}
|
|
|
|
func allocateSlot(slots map[string]dnfrepo.ItemStack, listType byte, preferred int16) (int16, error) {
|
|
ranges := [][2]int16{{9, 64}, {65, 120}, {121, 176}, {177, 232}, {233, 288}, {289, 344}, {345, 353}}
|
|
if listType == 1 {
|
|
ranges = [][2]int16{{0, 500}}
|
|
}
|
|
for _, bounds := range ranges {
|
|
if preferred >= bounds[0] && preferred <= bounds[1] {
|
|
if _, occupied := slots[inventoryKey(listType, preferred)]; !occupied {
|
|
return preferred, nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
for _, bounds := range ranges {
|
|
for slot := bounds[0]; slot <= bounds[1]; slot++ {
|
|
if _, occupied := slots[inventoryKey(listType, slot)]; !occupied {
|
|
return slot, nil
|
|
}
|
|
}
|
|
}
|
|
return 0, ErrInventoryFull
|
|
}
|
|
|
|
func inventoryKey(listType byte, slot int16) string { return fmt.Sprintf("%d:%d", listType, slot) }
|
|
func listingKey(id uint64) string { return listingPrefix + strconv.FormatUint(id, 10) }
|
|
func payoutKey(id uint64) string { return payoutPrefix + strconv.FormatUint(id, 10) }
|
|
func payoutKeyForSale(id uint64, remaining int64) string {
|
|
return payoutKey(id) + ":" + strconv.FormatInt(remaining, 10)
|
|
}
|
|
func deliveryKeyForSale(id uint64, remaining int64) string {
|
|
return deliveryPrefix + strconv.FormatUint(id, 10) + ":" + strconv.FormatInt(remaining, 10)
|
|
}
|
|
|
|
// deliveryKeyForReturn names the outbox row for a cancelled listing, and
|
|
// deliveryKeyForExpiry the row for one that ran out its duration. A sale key
|
|
// ends in the remaining count, so neither suffix can collide with one.
|
|
func deliveryKeyForReturn(id uint64) string {
|
|
return deliveryPrefix + strconv.FormatUint(id, 10) + ":return"
|
|
}
|
|
|
|
func deliveryKeyForExpiry(id uint64) string {
|
|
return deliveryPrefix + strconv.FormatUint(id, 10) + ":expired"
|
|
}
|
|
|
|
// deliveryKeyForBidWin names the outbox row that hands an expired listing to
|
|
// the bidder who won it, and refundKeyForBid the row that pays a released bid
|
|
// back. A refund is keyed by the amount it releases so that the same listing
|
|
// can outbid several bidders in turn without two rows colliding, and so a
|
|
// retried release is idempotent.
|
|
func deliveryKeyForBidWin(id uint64) string {
|
|
return deliveryPrefix + strconv.FormatUint(id, 10) + ":bid-win"
|
|
}
|
|
|
|
func refundKeyForBid(id uint64, bidderID string, total int64) string {
|
|
return refundKeyPrefixForBid(id, bidderID) + strconv.FormatInt(total, 10)
|
|
}
|
|
|
|
func refundKeyPrefixForBid(id uint64, bidderID string) string {
|
|
return payoutKey(id) + ":refund:" + bidderID + ":"
|
|
}
|
|
|
|
// listingExtraInt reads one optional numeric listing term. A missing key is
|
|
// the pre-charges default; a present but unparseable or out-of-range one is
|
|
// data corruption and retires the row rather than guessing.
|
|
func listingExtraInt(stack dnfrepo.ItemStack, key string, low, high int64) (int64, error) {
|
|
raw, present := stack.Extra[key]
|
|
if !present {
|
|
return 0, nil
|
|
}
|
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil || value < low || value > high {
|
|
return 0, ErrListingMissing
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func cloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
stack.Extra = cloneExtra(stack.Extra)
|
|
return stack
|
|
}
|
|
|
|
func cloneExtra(extra map[string]string) map[string]string {
|
|
result := make(map[string]string, len(extra)+8)
|
|
for key, value := range extra {
|
|
result[key] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (o *Owner) clock() time.Time {
|
|
if o != nil && o.now != nil {
|
|
return o.now().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|