Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

2265 lines
101 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfmail "longheng.io/server/internal/modules/dnf/mail"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
// Current NoPack class-0/op183 handler sub_1D80260 reads exactly
// {u8 service_type,u8 state}. Type 0 controls the ordinary auction entry;
// type 1 controls its companion service entry. Both must be open before the
// town UI will dispatch any class-1 auction request.
currentAuctionServiceNoticeMsgID = uint16(183)
currentAuctionServiceStateOpen = byte(1)
currentAuctionServiceTypeCount = byte(2)
currentAuctionPayTypeGold = byte(0)
currentAuctionPremiumType = premium.TypeAuction
currentAuctionStandardListingLimit = 5
currentAuctionPremiumListingLimit = 30
currentAuctionStandardDuration = 24 * time.Hour
currentAuctionPremiumDuration = 48 * time.Hour
currentAuctionRegisterRequestSize = 37
// currentAuctionPayTypeConsignment is the mode byte the live client writes
// into op182 when the request comes from the 金币寄售 (gold consignment)
// panel rather than the ordinary item auction. Live capture
// 2026-09-21T09:22Z, packet_log.txt: op182 body `017DEB280000` =
// mode 1 + item 2681725 (cash/chn_Trade_Gold/gold_1m.stk, 「1百万」) + one
// reserved zero. Those requests used to fall through the pay==0 gate and
// answer 153 「因不明原因操作失败」, which is the popup the panel showed.
currentAuctionPayTypeConsignment = byte(1)
// currentAuctionGoldAveragePriceRequestSize is that request's body length:
// {u8 mode=1, u32 goldItemId, u8 reserved}. The item auction sends 8 bytes.
currentAuctionGoldAveragePriceRequestSize = 6
// currentAuctionPriceAbsent is what the client writes into a price total
// the seller left empty. sub_3007EC0 normalizes both controls right before
// the send: the opening bid becomes -1 when it is 0 or equal to the buyout,
// and the buyout becomes -1 when it is 0.
currentAuctionPriceAbsent = int64(-1)
)
// Client-visible auction failure codes, read straight off the auction panel's
// shared dispatcher sub_301E3D0: it computes `code - 7`, refuses anything above
// 0xE4 (so only 7..235 reach the table), indexes the 229-byte table at
// 0x301E940 and jumps through 0x301E8E0. Only 7, 8, 140..158, 214 and 235 have
// a branch; **every other code lands in the silent default at 0x301E8C2 and
// draws nothing at all**. The whole auction used to answer with a single code
// 4, which is below the table's floor - a refused bid or registration looked
// exactly like a dead button. The comments are the dstr text each code renders
// (see docs/client-dstr-zh.txt).
const (
currentAuctionErrorGoldShort = byte(144) // 所持金币不足, 将无法进行上架或竞拍
currentAuctionErrorItemRejected = byte(145) // 无法上架到拍卖行的物品
currentAuctionErrorCountTooLarge = byte(146) // 无法上架超出持有数量的物品数
currentAuctionErrorItemMissing = byte(148) // 缺少物品
currentAuctionErrorNoListing = byte(150) // 拍卖行中不存在该物品
currentAuctionErrorListingLimit = byte(151) // 已超过上架数量上限, 需要拍卖行优惠券才能继续上架
currentAuctionErrorPriceInvalid = byte(152) // 最低竞拍价或一口价中输入了错误数值
currentAuctionErrorUnknown = byte(153) // 因不明原因操作失败
currentAuctionErrorForbidden = byte(154) // 您没有权限
currentAuctionErrorOwnListing = byte(155) // 无法竞拍自己上架的物品
currentAuctionErrorPriceMismatch = byte(157) // 竞拍金额不符, 可能存在更高价格的竞拍者
currentAuctionErrorItemLocked = byte(214) // 在物品已锁定状态下, 您无法进行当前操作
// currentAuctionFailureCode is what a refusal we cannot name more precisely
// sends. It has to stay inside the table above; "因不明原因操作失败" is the
// client's own wording for exactly that case.
currentAuctionFailureCode = currentAuctionErrorUnknown
)
// currentAuctionBuyFailureCode maps a buyout refusal onto the code the auction
// panel can actually render.
func currentAuctionBuyFailureCode(err error) byte {
switch {
case errors.Is(err, dnfauction.ErrPriceMismatch):
return currentAuctionErrorPriceInvalid
case errors.Is(err, dnfauction.ErrBuyerIsSeller):
return currentAuctionErrorOwnListing
case errors.Is(err, dnfauction.ErrGoldUnavailable):
return currentAuctionErrorGoldShort
case errors.Is(err, dnfauction.ErrListingMissing), errors.Is(err, dnfauction.ErrListingExpired):
return currentAuctionErrorNoListing
case errors.Is(err, dnfauction.ErrItemUnavailable):
return currentAuctionErrorItemMissing
default:
return currentAuctionErrorUnknown
}
}
// currentAuctionBidFailureCode is the same map for the 竞拍 half. A bid that
// does not beat the standing one is the case 157
// (竞拍金额不符, 可能存在更高价格的竞拍者) exists for, and now that bids are
// actually recorded that text is true rather than an invented rival: the
// listing really may already hold a higher bid. A listing registered
// buyout-only has no bid to beat, so that refusal stays on 152
// (最低竞拍价或一口价中输入了错误数值).
func currentAuctionBidFailureCode(err error) byte {
switch {
case errors.Is(err, dnfauction.ErrBidTooLow):
return currentAuctionErrorPriceMismatch
case errors.Is(err, dnfauction.ErrBiddingNotAllowed):
return currentAuctionErrorPriceInvalid
case errors.Is(err, dnfauction.ErrBuyerIsSeller):
return currentAuctionErrorOwnListing
case errors.Is(err, dnfauction.ErrGoldUnavailable):
return currentAuctionErrorGoldShort
case errors.Is(err, dnfauction.ErrListingMissing), errors.Is(err, dnfauction.ErrListingExpired):
return currentAuctionErrorNoListing
case errors.Is(err, dnfauction.ErrItemUnavailable):
return currentAuctionErrorItemMissing
default:
return currentAuctionErrorUnknown
}
}
// currentAuctionAccountContainerByte is the op183 container byte the current
// client writes when the listed stack lives in the account-scoped crystal/soul
// warehouse (wire list-0 slots 354..365). Character-inventory registrations
// carry 0 instead.
const currentAuctionAccountContainerByte byte = 36
// currentAuctionRejectReason 把一个域层拒绝翻成记录里能读的原因。
//
// 与上面两个 *FailureCode 分开:错误码要落在客户端已有的文案表里,只能是一个
// 0..255 的档;而记录是给运营看的,越具体越好,也不受表长限制。
//
// 同一个错误在全部拍卖行操作里都翻译成同一个词(拍卖行记录里只有这一套
// reason 词汇):这样"按 reason 统计最近有多少次因为金币不足被拒"一条查询就
// 能覆盖上架/竞拍/一口价三条路径,而不用记住三套写法。packet_log 里
// game-auction-buy-rejected 那条的 reason 是历史口径,保持不变。
func currentAuctionRejectReason(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, dnfauction.ErrGoldUnavailable):
return "gold_unavailable"
case errors.Is(err, dnfauction.ErrItemUnavailable):
return "item_unavailable"
case errors.Is(err, dnfauction.ErrPriceMismatch):
return "price_mismatch"
case errors.Is(err, dnfauction.ErrBuyerIsSeller):
return "buyer_is_seller"
case errors.Is(err, dnfauction.ErrBidTooLow):
return "bid_too_low"
case errors.Is(err, dnfauction.ErrBiddingNotAllowed):
return "bidding_not_allowed"
case errors.Is(err, dnfauction.ErrListingExpired):
return "listing_expired"
case errors.Is(err, dnfauction.ErrListingMissing):
return "listing_missing"
case errors.Is(err, dnfauction.ErrNotOwner):
return "not_owner"
case errors.Is(err, dnfauction.ErrListingLimit):
return "listing_limit"
case errors.Is(err, dnfauction.ErrInvalidRequest):
return "invalid_request"
case errors.Is(err, dnfauction.ErrUnavailable):
return "auction_unavailable"
default:
return "rejected"
}
}
// currentAuctionUnitPriceOfTotal 把整单金额折算回单价(记录用)。
//
// 客户端每次都是按"整单总价"发金额(竞拍与一口价都一样),而行上存的是单价;
// 记录里两个数都留着,运营才能分辨"单价 75 买 2 个"和"单价 150 买 1 个"。
// 除不尽时向下取整,与 currentAuctionTotalPrice 的乘法互为反向。
func currentAuctionUnitPriceOfTotal(total, count int64) int64 {
if total <= 0 || count <= 0 {
return 0
}
return total / count
}
type currentAuctionRegisterRequest struct {
PayType byte
ListType byte
Slot int16
ItemID int64
Count int64
StartPrice int64
InstantPrice int64
UnitPrice int64
DurationHours uint32
// AccountOwned records that the client marked the account-scoped
// crystal/soul warehouse as the container (op183 container byte 36). Those
// entries are still wire list-0 slots 354..365, so ListType stays 0 and the
// durable owner is the account inventory.
AccountOwned bool
}
func decodeCurrentAuctionRegisterRequest(body []byte) (currentAuctionRegisterRequest, error) {
// Current NoPack sender writes the container byte before the slot:
// {u8 pay,u8 container,u16 slot,u32 item,i32 count,i32 start,i32 instant,
// i32 unit,u32 duration_hours,9 zero reserved bytes}. The active client
// capture for slot 9/item 33608 is
// 00 00 09 00 48 83 00 00 01 00 00 00 ...; omitting container shifts every
// following field and rejects an otherwise valid registration at decode.
//
// The container byte is 0 for the character's own list 0 and 36 for the
// account-scoped crystal/soul warehouse, which is how a seller lists
// 无色小晶块 and the rest of the cube family: this server keeps those slots
// account-owned, and the client therefore never sends them as container 0.
// Any other value names a container this server cannot debit and stays
// refused.
if len(body) != currentAuctionRegisterRequestSize || body[0] != currentAuctionPayTypeGold {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
rawSlot := binary.LittleEndian.Uint16(body[2:4])
if rawSlot > 0x7fff {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
accountOwned := false
switch body[1] {
case currentExeInventoryMainListType:
case currentAuctionAccountContainerByte:
if !dnfrepo.IsAccountSharedInventorySlot(
dnfrepo.MainInventoryListType, int16(rawSlot)) {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
accountOwned = true
default:
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
request := currentAuctionRegisterRequest{
PayType: body[0],
ListType: currentExeInventoryMainListType,
AccountOwned: accountOwned,
Slot: int16(rawSlot),
ItemID: int64(binary.LittleEndian.Uint32(body[4:8])),
Count: int64(int32(binary.LittleEndian.Uint32(body[8:12]))),
StartPrice: int64(int32(binary.LittleEndian.Uint32(body[12:16]))),
InstantPrice: int64(int32(binary.LittleEndian.Uint32(body[16:20]))),
UnitPrice: int64(int32(binary.LittleEndian.Uint32(body[20:24]))),
DurationHours: binary.LittleEndian.Uint32(body[24:28]),
}
for _, value := range body[28:] {
if value != 0 {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
}
if request.Slot < 0 || request.ItemID <= 0 || request.Count <= 0 ||
(request.DurationHours != 24 && request.DurationHours != 48) {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
// 竞拍价 and 一口价 are independently optional in the client's own encoder.
// sub_3007BC0 refuses exactly two shapes - both controls empty (text
// 0xEAE2) and an opening bid above a set buyout (text 0xEAE4) - and lets a
// bid-only registration through. sub_3007EC0 then sends the empty control
// as -1 in its total slot, while the trailing per-item slot keeps the raw
// buyout and so stays 0. A bid-only body is therefore
// {start=<bid>, instant=-1, unit=0}, which the old "unit price must be
// positive and equal to instant" gate rejected at decode - that is why
// 只填竞拍价 could not list anything at all.
//
// The current client labels all three price controls "per item". For a
// stackable registration it therefore sends instant_price == unit_price;
// only the auction search/purchase projection multiplies by Count. Requiring
// instant_price to contain that total accepted single items but rejected
// every captured material stack before the transaction owner was reached.
hasBuyout := request.InstantPrice != currentAuctionPriceAbsent
if hasBuyout {
if request.UnitPrice <= 0 || request.InstantPrice != request.UnitPrice ||
request.UnitPrice > math.MaxInt32/request.Count {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
} else if request.UnitPrice != 0 {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
if request.StartPrice != currentAuctionPriceAbsent {
if request.StartPrice < 0 || request.StartPrice > math.MaxInt32/request.Count ||
(hasBuyout && request.StartPrice > request.InstantPrice) {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
}
if !hasBuyout && request.StartPrice <= 0 {
return currentAuctionRegisterRequest{}, dnfauction.ErrInvalidRequest
}
return request, nil
}
func (s *Service) sendCurrentAuctionServicesOpen(session *gameSession) error {
if s == nil || session == nil {
return nil
}
for serviceType := byte(0); serviceType < currentAuctionServiceTypeCount; serviceType++ {
bit := byte(1 << serviceType)
if session.auctionServiceOpenMask&bit != 0 {
continue
}
if err := s.sendGameUpperRawClass(
session,
currentAuctionServiceNoticeMsgID,
buildAuctionServiceNotice(serviceType, currentAuctionServiceStateOpen),
0,
); err != nil {
return err
}
session.auctionServiceOpenMask |= bit
}
return nil
}
// currentAuctionFailureNeedsPayFeature reports whether an auction failure ack
// must carry the trailing pay-feature byte. The class-1 auction handlers
// (op185 sub_1D17CA0, op188 sub_1D10910) special-case the security/level codes
// 0x69, 0x7B, 0x7E, 0x89, 0xCE, 0xD2 and 0xE8 and return without touching the
// cursor; for every other code they read one more byte and route on it - 0 to
// the gold panel (owner 7), 1 to its companion service (owner 8). Omit it and
// the client routes the refusal on whatever byte follows the packet.
func currentAuctionFailureNeedsPayFeature(opcode uint16, code byte) bool {
switch code {
case 0x69, 0x7B, 0x7E, 0x89, 0xCE, 0xD2, 0xE8:
return false
}
switch opcode {
case uint16(dnfenum.CmdPacketAuctionAskAveragePrice),
uint16(dnfenum.CmdPacketAuctionRegistItem),
uint16(dnfenum.CmdPacketAuctionRegistCancel),
uint16(dnfenum.CmdPacketAuctionBidding),
uint16(dnfenum.CmdPacketAuctionSearchByItemkey),
uint16(dnfenum.CmdPacketAuctionMyRegistedItemInfo),
uint16(dnfenum.CmdPacketAuctionMyBiddingInfo):
return true
default:
return false
}
}
func (s *Service) handleCurrentAuctionCommand(session *gameSession, opcode uint16, body []byte) error {
if session == nil || session.selectedCharacterID == 0 {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
s.auctionMu.Lock()
defer s.auctionMu.Unlock()
// Settling won listings before anything else is what makes bidding work
// without a scheduler: the sweep is seller- and bidder-agnostic, so any
// auction request from anybody closes every listing whose duration ran out
// under a standing bid. 86JP drives the same transition from a clock; this
// tree has no auction clock, and an expired listing has to stop being
// biddable and start being deliverable the moment somebody looks.
s.settleCurrentAuctionWonListingsLocked(session)
s.retryCurrentAuctionMailsLocked(session)
switch opcode {
case uint16(dnfenum.CmdPacketAuctionRegistItem):
return s.registerCurrentAuctionItemLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionRegistCancel):
return s.cancelCurrentAuctionItemLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionBidding):
return s.buyCurrentAuctionItemLocked(session, opcode, body, false)
case uint16(dnfenum.CmdPacketAuctionBuyItemApiece):
return s.buyCurrentAuctionItemLocked(session, opcode, body, true)
case uint16(dnfenum.CmdPacketAuctionSearchByItemkey):
s.rememberCurrentAuctionSearchLocked(session, opcode, body)
return s.searchCurrentAuctionItemsLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionSearchByNoitemkey):
s.rememberCurrentAuctionSearchLocked(session, opcode, body)
return s.searchCurrentAuctionCategoryLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionAskAveragePrice):
return s.sendCurrentAuctionAveragePriceLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionMyRegistedItemInfo):
return s.sendCurrentAuctionOwnedItemsLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionMyBiddingInfo):
return s.sendCurrentAuctionBiddingItemsLocked(session, body)
default:
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
}
func (s *Service) currentAuctionOwner() (*dnfauction.Owner, error) {
repositories, ok := s.repositoryGroup()
if !ok {
return nil, dnfauction.ErrUnavailable
}
return dnfauction.NewOwner(repositories)
}
// currentAuctionRegisterFailureCode names a registration refusal for the client
// by the stage that produced it. `decode` and `duration` are both "the seller
// typed a value this listing cannot carry", which is what 152 says; the item
// stages have their own texts; everything left is 因不明原因操作失败.
func currentAuctionRegisterFailureCode(stage string, cause error) byte {
switch stage {
case "decode", "duration":
return currentAuctionErrorPriceInvalid
case "source_mismatch":
return currentAuctionErrorItemMissing
case "item_locked":
return currentAuctionErrorItemLocked
case "item_policy":
return currentAuctionErrorItemRejected
case "count":
return currentAuctionErrorCountTooLarge
case "deposit_gold":
return currentAuctionErrorGoldShort
}
switch {
case errors.Is(cause, dnfauction.ErrListingLimit):
return currentAuctionErrorListingLimit
case errors.Is(cause, dnfauction.ErrGoldUnavailable):
return currentAuctionErrorGoldShort
case errors.Is(cause, dnfauction.ErrItemUnavailable):
return currentAuctionErrorItemMissing
default:
return currentAuctionErrorUnknown
}
}
// currentAuctionSourceSlots returns the slot map a registration draws from: the
// seller character's list 0, or the account-scoped crystal/soul warehouse when
// the client marked container 36.
func (s *Service) currentAuctionSourceSlots(
ctx context.Context,
repositories dnfrepo.Group,
session *gameSession,
request currentAuctionRegisterRequest,
) (map[string]dnfrepo.ItemStack, error) {
if !request.AccountOwned {
inventory, found, err := repositories.Inventory.Load(ctx, selectedCharacterIDString(session))
if err != nil || !found {
return nil, err
}
return inventory.Slots, nil
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
return nil, dnfauction.ErrUnavailable
}
account, found, err := repositories.AccountInventory.Load(ctx, accountID)
if err != nil {
return nil, err
}
if !found {
// No warehouse row yet is an empty warehouse, not a broken account: the
// caller reports it as a missing item.
return nil, nil
}
return account.Slots, nil
}
func (s *Service) registerCurrentAuctionItemLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionRegistItem)
// 金币寄售 registers on the same opcode with a 28-byte mode-1 body. Live
// capture 2026-09-21T09:32:48Z: mode 1 + tier item + qty 1 + bid -1 + Cera
// price + 24h. Only that exact length and mode is diverted; the item
// auction's 37-byte mode-0 body keeps the path below byte for byte.
if len(body) == currentAuctionGoldRegisterRequestSize &&
body[0] == currentAuctionPayTypeConsignment {
return s.registerCurrentGoldConsignLocked(session, opcode, body)
}
var request currentAuctionRegisterRequest
reject := func(stage string, cause error) error {
code := currentAuctionRegisterFailureCode(stage, cause)
fields := []any{
"stage", stage,
"body_len", len(body),
"char_id", selectedCharacterIDString(session),
"slot", request.Slot,
"item_id", request.ItemID,
"count", request.Count,
"duration_hours", request.DurationHours,
"account_owned", request.AccountOwned,
"client_code", code,
}
if cause != nil {
fields = append(fields, "error", cause.Error())
}
s.logGameEvent(session, "game-auction-register-rejected", fields...)
// 被拒绝的上架同样留一条记录(result=rejected + reason=阶段名)。
// 上架失败以前只在 packet_log 里留下一行 stage=...,而 packet_log
// 在高负载下会丢行;把它落到记录里,"他为什么挂不上"才有据可查。
rejected := activityRejectionFields(stage)
rejected["body_len"] = len(body)
rejected["item_id"] = request.ItemID
rejected["item_count"] = request.Count
rejected["source_slot"] = int(request.Slot)
rejected["unit_price"] = request.UnitPrice
rejected["start_price"] = request.StartPrice
rejected["buyout_price"] = request.InstantPrice
rejected["duration_hours"] = request.DurationHours
rejected["client_code"] = code
s.recordAuction(session, "list", rejected)
return s.sendGameUpperFailure(session, opcode, code)
}
var err error
request, err = decodeCurrentAuctionRegisterRequest(body)
if err != nil {
return reject("decode", err)
}
const listType = byte(0)
slot := request.Slot
itemID := request.ItemID
requestedCount := request.Count
// UnitPrice is the price the listing actually sells at. With a 一口价 that
// is the buyout; a bid-only listing has none, so its opening bid is the
// asking price and BuyoutPrice records that there is no buyout at all.
unitPrice := request.UnitPrice
buyoutPrice := request.UnitPrice
if request.InstantPrice == currentAuctionPriceAbsent {
unitPrice = request.StartPrice
buyoutPrice = currentAuctionPriceAbsent
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Inventory == nil || repositories.Character == nil ||
(request.AccountOwned && repositories.AccountInventory == nil) {
return reject("repository", dnfauction.ErrUnavailable)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID := selectedCharacterIDString(session)
sourceSlots, err := s.currentAuctionSourceSlots(ctx, repositories, session, request)
if err != nil {
return reject("load_inventory", err)
}
stack, found := sourceSlots[inventorySlotKey(listType, slot)]
if !found || stack.ItemID != itemID || stack.Count <= 0 {
return reject("source_mismatch", dnfauction.ErrItemUnavailable)
}
// A locked item is refused by the policy below too, but "无法上架到拍卖行的
// 物品" would send the seller looking for a trade restriction that is not
// there. The client has its own text for a lock, so name it.
if currentDungeonDiscardStackLocked(stack) {
return reject("item_locked", dnfauction.ErrItemUnavailable)
}
expectedBind, allowSealed, eligible := s.currentAuctionRegistrationPolicy(uint32(itemID), stack)
if !eligible {
return reject("item_policy", dnfauction.ErrItemUnavailable)
}
count := requestedCount
if count > stack.Count {
return reject("count", dnfauction.ErrItemUnavailable)
}
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil || !found {
return reject("load_character", err)
}
listingLimit, maximumDuration, feePercent := s.currentAuctionEntitlementLocked(ctx, session, repositories)
duration := time.Duration(request.DurationHours) * time.Hour
if duration > maximumDuration {
return reject("duration", dnfauction.ErrInvalidRequest)
}
owner, err := dnfauction.NewOwner(repositories)
if err != nil {
return reject("owner", err)
}
var result dnfauction.Result
for attempts := 0; attempts < 8; attempts++ {
listingID := s.nextAuctionListingID.Add(1)
result, err = owner.Register(ctx, dnfauction.RegisterCommand{
ListingID: listingID, SellerID: characterID, AccountID: strings.TrimSpace(s.accountIDForSession(session)),
SellerName: character.Name, ListType: listType, SourceSlot: slot, ExpectedID: itemID,
Count: count, UnitPrice: unitPrice, StartPrice: request.StartPrice, BuyoutPrice: buyoutPrice,
Deposit: dnfauction.ListingDepositGold, FeePercent: feePercent,
Duration: duration, ListingLimit: listingLimit,
ExpectedBind: expectedBind, AllowSealed: allowSealed,
SourceAccountOwned: request.AccountOwned,
})
if !errors.Is(err, dnfauction.ErrListingIDExists) {
break
}
}
if err != nil {
if errors.Is(err, dnfauction.ErrGoldUnavailable) {
// A seller who cannot cover the registration deposit is a routine
// refusal, not a fault; it must not read as a broken transaction.
return reject("deposit_gold", err)
}
return reject("transaction", err)
}
s.logGameEvent(session, "game-auction-register-committed",
"char_id", characterID,
"listing_id", result.Listing.ID,
"slot", slot,
"item_id", itemID,
"count", count,
"unit_price", unitPrice,
"start_price", request.StartPrice,
"buyout_price", buyoutPrice,
"deposit", dnfauction.ListingDepositGold,
"fee_percent", feePercent,
"gold_after", result.GoldAfter,
"duration_hours", request.DurationHours)
if err := s.sendGameUpperSuccess(session, opcode, buildCurrentAuctionRegisterSuccessBody(result.Listing.ID, session.robotSessionBound)); err != nil {
return err
}
if err := s.sendCurrentAuctionInventorySlot(session, listType, slot, result.Inventory); err != nil {
return err
}
// The deposit left the wallet inside the same transaction, so the gold row
// has to be republished or the client keeps showing the pre-listing total.
if err := s.sendGameUpperRawClass(session, 14, buildCurrentGoldStateBody(result.GoldAfter), 0); err != nil {
return err
}
// 上架记录:一次成功的上架就是一次状态跃迁(物品离开背包、托管押金被扣、
// 列表里多出一行),所以它只写在这条成功路径上,不写在任何扫描/重试入口。
fields := map[string]any{
"listing_id": result.Listing.ID,
"item_id": itemID,
"item_name": s.currentAuctionItemDisplayName(itemID),
"item_count": count,
"list_type": int(listType),
"source_slot": int(slot),
"unit_price": unitPrice,
"start_price": request.StartPrice,
"buyout_price": buyoutPrice,
"deposit_gold": dnfauction.ListingDepositGold,
"fee_percent": feePercent,
"duration_hours": request.DurationHours,
"gold_after": result.GoldAfter,
"seller_id": strings.TrimSpace(result.Listing.SellerID),
"seller_name": strings.TrimSpace(result.Listing.SellerName),
"account_owned": request.AccountOwned,
}
if !result.Listing.ExpiresAt.IsZero() {
fields["expires_at"] = result.Listing.ExpiresAt.UTC().Format(time.RFC3339)
}
activityIdentityOverride(fields, strings.TrimSpace(s.accountIDForSession(session)), characterID)
s.recordAuction(session, "list", fields)
return nil
}
func buildCurrentAuctionRegisterSuccessBody(listingID uint64, robotSessionBound bool) []byte {
var response packetWriter
response.writeByte(currentAuctionPayTypeGold)
// The stock client consumes only payType. Authenticated current90 robots get
// a private trailing listing ID so the reconstructed market worker can later
// buy/cancel its own listing without changing the public client contract.
if robotSessionBound {
response.writeUint64(listingID)
}
return response.bytes()
}
// currentAuctionEntitlementLocked resolves the three 拍卖行优惠券 effects this
// server can act on when a registration arrives: the active-listing cap, the
// selectable duration, and the sale commission. The coupon's own PVF explain
// text promises 减少使用拍卖行所需的手续费, 可同时上架30个物品 and
// 可选择拍卖期限48小时; the discounted rate itself is an operator setting,
// because that text names no number.
func (s *Service) currentAuctionEntitlementLocked(ctx context.Context, session *gameSession, repositories dnfrepo.Group) (int, time.Duration, int64) {
if repositories.Account == nil {
return currentAuctionStandardListingLimit, currentAuctionStandardDuration, dnfauction.SaleFeePercentFor(false)
}
account, found, err := repositories.Account.Load(ctx, strings.TrimSpace(s.accountIDForSession(session)))
if err == nil && found && premium.ExpireAtRaw(account, currentAuctionPremiumType) > time.Now().Unix() {
return currentAuctionPremiumListingLimit, currentAuctionPremiumDuration, dnfauction.SaleFeePercentFor(true)
}
return currentAuctionStandardListingLimit, currentAuctionStandardDuration, dnfauction.SaleFeePercentFor(false)
}
func (s *Service) cancelCurrentAuctionItemLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionRegistCancel)
// 金币寄售 cancels on the same opcode and, on the item panel's own shape,
// with a 9-byte mode-1 body ({u8 mode, i64 listingId}). INFERRED: listing
// did not work before this change, so the gold cancel has never been
// captured. See the gold protocol file.
if len(body) == currentAuctionGoldCancelRequestSize &&
body[0] == currentAuctionPayTypeConsignment {
return s.cancelCurrentGoldConsignLocked(session, opcode, body)
}
if len(body) != 9 || body[0] != currentAuctionPayTypeGold {
rejected := activityRejectionFields("malformed_body")
rejected["body_len"] = len(body)
s.recordAuction(session, "cancel", rejected)
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
rejected := activityRejectionFields("auction_unavailable")
rejected["body_len"] = len(body)
s.recordAuction(session, "cancel", rejected)
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
listingID := binary.LittleEndian.Uint64(body[1:9])
result, err := owner.Cancel(ctx, dnfauction.CancelCommand{
SellerID: selectedCharacterIDString(session), AccountID: strings.TrimSpace(s.accountIDForSession(session)),
ListingID: listingID,
})
if err != nil {
code := currentAuctionErrorUnknown
switch {
case errors.Is(err, dnfauction.ErrListingMissing), errors.Is(err, dnfauction.ErrListingExpired):
code = currentAuctionErrorNoListing
case errors.Is(err, dnfauction.ErrNotOwner):
code = currentAuctionErrorForbidden
}
s.logGameEvent(session, "game-auction-cancel-rejected",
"char_id", selectedCharacterIDString(session), "listing_id", listingID,
"client_code", code, "error", err.Error())
rejected := activityRejectionFields(currentAuctionRejectReason(err))
rejected["listing_id"] = listingID
rejected["client_code"] = code
s.recordAuction(session, "cancel", rejected)
return s.sendGameUpperFailure(session, opcode, code)
}
// The ACK goes out before the return mail. That is the order every other
// command uses - and what the gold consignment cancel already does: the
// client's op184 handler pops its own toast the moment it consumes the ACK,
// while the return letter, its mailbox alarm and the 60091 banner are
// notifications that belong behind it. Keeping them in front of the ACK
// would also leave the 60091 banner racing an outstanding op184 request,
// which the class-1 dispatcher is free to consume as part of the pending
// transition.
if err := s.sendGameUpperSuccess(session, opcode, []byte{currentAuctionPayTypeGold}); err != nil {
return err
}
if result.Delivery.ID != "" {
if deliveryErr := s.deliverCurrentAuctionReturnMailLocked(ctx, owner, result.Delivery); deliveryErr != nil {
// The item is already parked in the durable outbox, so a mail
// failure defers the delivery instead of losing it: the next
// pending-delivery sweep sends it.
s.logGameEvent(session, "game-auction-return-mail-deferred",
"delivery_id", result.Delivery.ID, "seller_id", result.Delivery.BuyerID,
"listing_id", result.Delivery.ListingID, "item_id", result.Delivery.Item.ItemID,
"error", deliveryErr)
}
}
// 下架记录:Cancel 成功就是一次跃迁(挂单行被移除、返还邮件入队、站着的
// 竞价被释放),所以只写在成功分支上。押金不退还这件事记录里要看得见:
// 取消上架会没收押金(客户端自己的确认弹窗就这么写),而流拍会退还,
// 两条路径的 deposit_gold 因此含义相反,字段必须都留着。
fields := map[string]any{
"listing_id": result.Listing.ID,
"item_id": result.Listing.Item.ItemID,
"item_name": s.currentAuctionItemDisplayName(result.Listing.Item.ItemID),
"item_count": result.Listing.Item.Count,
"list_type": int(result.Listing.ListType),
"source_slot": int(result.Listing.SourceSlot),
"unit_price": result.Listing.UnitPrice,
"start_price": result.Listing.StartPrice,
"buyout_price": result.Listing.BuyoutPrice,
"deposit_gold": result.Listing.Deposit,
"fee_percent": result.Listing.FeePercent,
"seller_id": strings.TrimSpace(result.Listing.SellerID),
"seller_name": strings.TrimSpace(result.Listing.SellerName),
}
// 下架时站着的竞价会被释放(releaseListingBid),金额进 Payout 退款行。
// 只有真的释放过才写这两栏:写一个空的 outbid_id 会让人以为存在一个
// 空名字的竞拍者。
if outbidID := strings.TrimSpace(result.Refund.SellerID); outbidID != "" {
fields["outbid_id"] = outbidID
fields["outbid_gold"] = result.Refund.Gold
}
if !result.Listing.ExpiresAt.IsZero() {
fields["expires_at"] = result.Listing.ExpiresAt.UTC().Format(time.RFC3339)
}
activityIdentityOverride(fields,
strings.TrimSpace(s.accountIDForSession(session)), selectedCharacterIDString(session))
s.recordAuction(session, "cancel", fields)
return nil
}
// rememberCurrentAuctionSearchLocked keeps the last search this session ran so
// a successful bid can replay it, and replayCurrentAuctionSearchLocked re-runs
// it. Both only ever run under auctionMu.
func (s *Service) rememberCurrentAuctionSearchLocked(session *gameSession, opcode uint16, body []byte) {
if session == nil {
return
}
session.auctionLastSearchOpcode = opcode
session.auctionLastSearchBody = append([]byte(nil), body...)
}
// replayCurrentAuctionSearchLocked re-sends the list the player is looking at.
// The client's op185 success path erases the row it acted on before it draws
// the toast (sub_30071B0 removes the entry whose listing id matches the
// selection), which is right for a buyout and leaves a bid looking like the
// listing vanished. The response is the ordinary class-1 op186 ack: the
// dispatcher at 0x225C8A0 gates class-1 packets on one global transition flag,
// not on a per-request pending table, so an unsolicited one dispatches exactly
// like the player's own search.
func (s *Service) replayCurrentAuctionSearchLocked(session *gameSession) error {
if session == nil || len(session.auctionLastSearchBody) == 0 {
return nil
}
body := append([]byte(nil), session.auctionLastSearchBody...)
switch session.auctionLastSearchOpcode {
case uint16(dnfenum.CmdPacketAuctionSearchByItemkey):
return s.searchCurrentAuctionItemsLocked(session, body)
case uint16(dnfenum.CmdPacketAuctionSearchByNoitemkey):
return s.searchCurrentAuctionCategoryLocked(session, body)
default:
return nil
}
}
// currentAuctionAmountIsBid decides which half of the bidding opcode a request
// is. Anything short of the buyout total is a 竞拍; a listing with no buyout at
// all can only be bid on. A listing that cannot be read is left to the
// transactional path, which reports the real reason.
func (s *Service) currentAuctionAmountIsBid(ctx context.Context, owner *dnfauction.Owner, listingID uint64, money int64) bool {
listing, err := owner.LoadListing(ctx, listingID)
if err != nil {
return false
}
if !listing.HasBuyout() {
return true
}
return money < listing.BuyoutTotal()
}
// bidCurrentAuctionItemLocked records a 竞拍 and answers the same op185 ack a
// buyout does: the client's success branch only refreshes the panel and toasts
// 已完成竞拍, so a recorded bid and a completed buyout look alike to it. The
// bidder's gold left their wallet, so the wallet row has to be republished.
func (s *Service) bidCurrentAuctionItemLocked(ctx context.Context, session *gameSession, opcode uint16, owner *dnfauction.Owner, listingID uint64, expectedItemID, money int64) error {
result, err := owner.Bid(ctx, dnfauction.BidCommand{
BidderID: selectedCharacterIDString(session), AccountID: strings.TrimSpace(s.accountIDForSession(session)),
ListingID: listingID, ExpectedItemID: expectedItemID, Money: money,
})
if err != nil {
code := currentAuctionBidFailureCode(err)
s.logGameEvent(session, "game-auction-bid-rejected",
"char_id", selectedCharacterIDString(session), "listing_id", listingID,
"item_id", expectedItemID, "money", money, "client_code", code, "error", err.Error())
rejected := activityRejectionFields(currentAuctionRejectReason(err))
rejected["listing_id"] = listingID
rejected["item_id"] = expectedItemID
rejected["bid_total"] = money
rejected["client_code"] = code
s.recordAuction(session, "bid", rejected)
return s.sendGameUpperFailure(session, opcode, code)
}
// The outbid player's gold is already parked in the durable refund row, so
// a failure here defers the payment instead of losing it.
if result.Refund.ID != "" {
if deliveryErr := s.settleCurrentAuctionPayoutLocked(ctx, owner, result.Refund); deliveryErr != nil {
s.logGameEvent(session, "game-auction-bid-refund-deferred",
"refund_id", result.Refund.ID, "bidder_id", result.Refund.SellerID,
"listing_id", result.Refund.ListingID, "gold", result.Refund.Gold, "error", deliveryErr)
}
}
s.logGameEvent(session, "game-auction-bid-accepted",
"char_id", selectedCharacterIDString(session), "listing_id", listingID,
"item_id", expectedItemID, "bid_total", money,
"outbid_id", result.Refund.SellerID, "outbid_gold", result.Refund.Gold,
"gold_after", result.GoldAfter)
if err := s.sendGameUpperSuccess(session, opcode, []byte{currentAuctionPayTypeGold}); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, 14, buildCurrentGoldStateBody(result.GoldAfter), 0); err != nil {
return err
}
// 出价记录:Bid 成功就是一次跃迁(新竞价写入挂单行、出价方金币进托管、
// 被超越者的钱进退款行),所以只写在成功分支上——每个拍卖命令开头的那次
// 结算扫描不会走到这里,重复执行同一条命令也只会因为 ErrBidTooLow 被拒。
fields := map[string]any{
"listing_id": result.Listing.ID,
"item_id": result.Listing.Item.ItemID,
"item_name": s.currentAuctionItemDisplayName(result.Listing.Item.ItemID),
"item_count": result.Listing.Item.Count,
"list_type": int(result.Listing.ListType),
"source_slot": int(result.Listing.SourceSlot),
"unit_price": result.Listing.UnitPrice,
"start_price": result.Listing.StartPrice,
"buyout_price": result.Listing.BuyoutPrice,
"deposit_gold": result.Listing.Deposit,
"fee_percent": result.Listing.FeePercent,
"bid_total": money,
// 行上存的是单价,客户端发的是整单总价,两个都留一份。
"bid_unit_price": currentAuctionUnitPriceOfTotal(money, result.Listing.Item.Count),
"gold_after": result.GoldAfter,
"seller_id": strings.TrimSpace(result.Listing.SellerID),
"seller_name": strings.TrimSpace(result.Listing.SellerName),
"bidder_id": strings.TrimSpace(result.Listing.BidderID),
}
// 被超越的那位:钱已经从托管退回(钱包直充或邮件),金额一并记下。
if outbidID := strings.TrimSpace(result.Refund.SellerID); outbidID != "" {
fields["outbid_id"] = outbidID
fields["outbid_gold"] = result.Refund.Gold
}
if !result.Listing.ExpiresAt.IsZero() {
fields["expires_at"] = result.Listing.ExpiresAt.UTC().Format(time.RFC3339)
}
activityIdentityOverride(fields,
strings.TrimSpace(s.accountIDForSession(session)), selectedCharacterIDString(session))
s.recordAuction(session, "bid", fields)
// The client drops the row it just bid on, so hand the list back with the
// new current price on it. Without this the panel reads as empty until the
// player searches again.
return s.replayCurrentAuctionSearchLocked(session)
}
func (s *Service) buyCurrentAuctionItemLocked(session *gameSession, opcode uint16, body []byte, apiece bool) error {
// 金币寄售 buys on op185 with a compact mode-1 body. INFERRED: it has never
// been captured, because listing did not work before this change. The item
// panel's op185 is 30 bytes and mode 0, so the two cannot collide, and the
// gold decoder insists on its own 13/17-byte length as well.
//
// op334 is deliberately NOT diverted: the item body there carries no mode
// byte at all (33 bytes beginning with the money field), so a gold variant
// of op334, if one exists, is still unknown and must stay on the item path
// until a capture shows its shape.
if !apiece && len(body) > 0 && body[0] == currentAuctionPayTypeConsignment {
return s.buyCurrentGoldConsignLocked(session, opcode, body)
}
var money int64
var count int64 = 1
var listingID uint64
var expectedItemID int64
if apiece {
if len(body) != 33 {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
money = int64(int32(binary.LittleEndian.Uint32(body[0:4])))
count = int64(int32(binary.LittleEndian.Uint32(body[4:8])))
listingID = binary.LittleEndian.Uint64(body[8:16])
expectedItemID = int64(binary.LittleEndian.Uint32(body[29:33]))
} else {
if len(body) != 30 || body[0] != currentAuctionPayTypeGold {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
money = int64(int32(binary.LittleEndian.Uint32(body[1:5])))
listingID = binary.LittleEndian.Uint64(body[5:13])
expectedItemID = int64(binary.LittleEndian.Uint32(body[26:30]))
}
if money <= 0 || (apiece && count <= 0) || listingID == 0 || expectedItemID <= 0 {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
// The bidding opcode carries one amount and no verb: the client asks for a
// buyout by sending exactly the buyout total and for a 竞拍 by sending
// anything less. 86JP's AuctionBidService.TryBid draws the line in the same
// place from the other side (`bidAmount >= InstantPrice` is not a bid).
// Only the whole-listing form can bid; op334 is per-piece buyout only.
if !apiece && s.currentAuctionAmountIsBid(ctx, owner, listingID, money) {
return s.bidCurrentAuctionItemLocked(ctx, session, opcode, owner, listingID, expectedItemID, money)
}
result, err := owner.Buy(ctx, dnfauction.BuyCommand{
BuyerID: selectedCharacterIDString(session), AccountID: strings.TrimSpace(s.accountIDForSession(session)),
ListingID: listingID, ExpectedItemID: expectedItemID, Count: count, Money: money, WholeListing: !apiece,
})
if err != nil {
// Naming the refusal keeps a player who cannot pay apart from one whose
// amount does not match and from a listing that is simply gone. A
// sub-buyout amount never reaches here any more: it is routed to the
// bid path above.
event, reason := "game-auction-buy-rejected", "transaction"
switch {
case errors.Is(err, dnfauction.ErrBuyerIsSeller):
reason = "buyer_is_seller"
case errors.Is(err, dnfauction.ErrPriceMismatch):
reason = "price_mismatch"
case errors.Is(err, dnfauction.ErrGoldUnavailable):
reason = "buyer_cannot_afford"
case errors.Is(err, dnfauction.ErrListingExpired):
reason = "listing_expired"
case errors.Is(err, dnfauction.ErrListingMissing):
reason = "listing_missing"
}
code := currentAuctionBuyFailureCode(err)
s.logGameEvent(session, event,
"char_id", selectedCharacterIDString(session),
"listing_id", listingID,
"item_id", expectedItemID,
"count", count,
"whole_listing", !apiece,
"money", money,
"reason", reason,
"client_code", code,
"error", err.Error())
// 记录里的 reason 用统一词汇表(currentAuctionRejectReason),
// 上面 logGameEvent 的那套是本条日志的历史口径,保持不动。
rejected := activityRejectionFields(currentAuctionRejectReason(err))
rejected["listing_id"] = listingID
rejected["item_id"] = expectedItemID
rejected["item_count"] = count
rejected["sale_price"] = money
rejected["whole_listing"] = !apiece
rejected["client_code"] = code
s.recordAuction(session, "buyout", rejected)
return s.sendGameUpperFailure(session, opcode, code)
}
if result.Delivery.ID != "" {
if deliveryErr := s.deliverCurrentAuctionBuyerMailLocked(ctx, owner, result.Delivery); deliveryErr != nil {
s.logGameEvent(session, "game-auction-buyer-mail-deferred", "delivery_id", result.Delivery.ID, "buyer_id", result.Delivery.BuyerID, "item_id", result.Delivery.Item.ItemID, "count", result.Delivery.Item.Count, "error", deliveryErr)
}
}
// 卖家收益的邮件是否真的发出去了,会写进收益记录:这一行是在成交那一刻
// 写的(Payout 行由 Buy 当场新建),而信可能延后,记录不能默认它已经送达。
payoutMailed := false
if result.Payout.ID != "" {
if deliveryErr := s.deliverCurrentAuctionPayoutMailLocked(ctx, owner, result.Payout); deliveryErr != nil {
s.logGameEvent(session, "game-auction-payout-mail-deferred", "payout_id", result.Payout.ID, "seller_id", result.Payout.SellerID, "gold", result.Payout.Gold, "error", deliveryErr)
} else {
payoutMailed = true
}
}
// A buyout over somebody else's standing bid releases that bid.
if result.Refund.ID != "" {
if deliveryErr := s.settleCurrentAuctionPayoutLocked(ctx, owner, result.Refund); deliveryErr != nil {
s.logGameEvent(session, "game-auction-bid-refund-deferred", "refund_id", result.Refund.ID, "bidder_id", result.Refund.SellerID, "listing_id", result.Refund.ListingID, "gold", result.Refund.Gold, "error", deliveryErr)
}
}
var response packetWriter
if !apiece {
response.writeByte(currentAuctionPayTypeGold)
}
if err := s.sendGameUpperSuccess(session, opcode, response.bytes()); err != nil {
return err
}
if err := s.sendGameUpperRawClass(session, 14, buildCurrentGoldStateBody(result.GoldAfter), 0); err != nil {
return err
}
// 一口价成交记录:Buy 成功就是一次跃迁(挂单行被扣、买家金币进托管、
// 物品进退货行、卖家收益进收益行)。它只写在这条成功路径上,重复发同一个
// op 只会因为挂单已不存在被拒,不会走到这里。
buyout := map[string]any{
"listing_id": result.Listing.ID,
"item_id": result.Listing.Item.ItemID,
"item_name": s.currentAuctionItemDisplayName(result.Listing.Item.ItemID),
"item_count": result.Listing.Item.Count,
"list_type": int(result.Listing.ListType),
"source_slot": int(result.Listing.SourceSlot),
"unit_price": result.Listing.UnitPrice,
"start_price": result.Listing.StartPrice,
"buyout_price": result.Listing.BuyoutPrice,
"deposit_gold": result.Listing.Deposit,
"fee_percent": result.Listing.FeePercent,
"sale_price": money,
"gold_after": result.GoldAfter,
"buyout": true,
"whole_listing": !apiece,
"seller_id": strings.TrimSpace(result.Listing.SellerID),
"seller_name": strings.TrimSpace(result.Listing.SellerName),
"buyer_id": strings.TrimSpace(result.Delivery.BuyerID),
"buyer_name": s.activityCharacterName(session),
}
// 卖家实得与手续费来自 Payout 行上冻结的成交明细(HasStatement),
// 拿不到就留空 —— 用 FeePercent 现算会与真实付款对不上(旧挂单费率不同)。
if result.Payout.HasStatement {
buyout["fee_gold"] = result.Payout.Statement.FeeGold
buyout["seller_proceeds"] = result.Payout.Gold
} else if result.Payout.ID != "" {
buyout["seller_proceeds"] = result.Payout.Gold
}
if outbidID := strings.TrimSpace(result.Refund.SellerID); outbidID != "" {
buyout["outbid_id"] = outbidID
buyout["outbid_gold"] = result.Refund.Gold
}
if !result.Listing.ExpiresAt.IsZero() {
buyout["expires_at"] = result.Listing.ExpiresAt.UTC().Format(time.RFC3339)
}
activityIdentityOverride(buyout,
strings.TrimSpace(s.accountIDForSession(session)), selectedCharacterIDString(session))
s.recordAuction(session, "buyout", buyout)
// 卖家收益记录:Payout 行是 Buy 当场新建的(一行一单,重跑同一个扫描不会
// 再产出同一行),所以"行新建的那一次"就是这里。延后重发的那些轮次
// (retryCurrentAuctionMailsLocked)拿到的都是老行,一律不记。
if result.Payout.ID != "" {
s.recordAuction(session, "payout", auctionPayoutRecordFields(s, result.Payout, payoutMailed))
}
return nil
}
// auctionPayoutRecordFields 组装一条"卖家收益"记录。
//
// 两个入口共用:一口价成交(Buy 返回的新 Payout 行)与竞拍成交
// (SettleWonListings 返回的每一条 Settlement 里的 Payout 行)。两处的钱都
// 一样从托管钱包走到卖家手里(成交款 + 保证金 - 手续费),字段口径必须一致。
//
// delivered 记的是"这一轮信发出去了没有":Payout 行在成交那一刻就已经落库
// (钱确实从托管划走了),而邮件可能因为任何原因延后 —— 记录里如实写出来,
// 免得运营把"已划账但信还没发"读成"信已经收到了"。
func auctionPayoutRecordFields(s *Service, payout dnfauction.Payout, mailed bool) map[string]any {
fields := map[string]any{
"listing_id": payout.ListingID,
"item_id": payout.ItemID,
"item_name": s.currentAuctionItemDisplayName(payout.ItemID),
"item_count": payout.SoldCount,
"payout_id": payout.ID,
"seller_id": strings.TrimSpace(payout.SellerID),
"seller_name": strings.TrimSpace(payout.SellerName),
"seller_proceeds": payout.Gold,
"refund": payout.Refund,
"delivered": mailed,
}
if payout.HasStatement {
fields["sale_price"] = payout.Statement.GrossGold
fields["fee_gold"] = payout.Statement.FeeGold
fields["deposit_gold"] = payout.Statement.DepositGold
}
return fields
}
func (s *Service) searchCurrentAuctionItemsLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionSearchByItemkey)
// 金币寄售 shares op186 with the item auction and reuses its page/count/
// itemId layout, but sets mode 1 and omits the 8-byte detail tail. Live
// capture 2026-09-21T09:35:47Z, packet_log.txt: op186 body
// `0100000000001F01A59C7DEB2800` = mode 1 + page 0 + count 1 +
// category 40101 + item 2681725 (「1百万」), 14 bytes, no tail. Only that
// mode byte is diverted, so every item-panel search keeps the path below.
if len(body) > 0 && body[0] == currentAuctionPayTypeConsignment {
return s.searchCurrentAuctionGoldItemsLocked(session, opcode, body)
}
page, itemIDs, ok := decodeCurrentAuctionSearchItemIDs(body)
if !ok {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
listings, err := owner.Search(ctx, itemIDs)
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
bodyOut := buildCurrentAuctionSearchBodyPage(listings, s.currentAuctionItemEligible, page, 100)
return s.sendGameUpperSuccess(session, opcode, bodyOut)
}
// searchCurrentAuctionCategoryLocked answers the 拍卖行 "分类搜索" page: the
// browse-by-tree-node request the player drives by clicking a category in the
// left panel.
//
// The body is the fixed 20-byte request NoPack.exe sends, and the selected tree
// node's u16 code sits at body[5:7]; the rest of the request is carried but not
// interpreted (see currentAuctionCategoryRequestCode). The 金币寄售 panel reuses
// this opcode with mode 1 and is diverted to searchCurrentGoldConsignCategoryLocked
// before any item-auction parsing happens.
func (s *Service) searchCurrentAuctionCategoryLocked(session *gameSession, body []byte) error {
responseOpcode := uint16(dnfenum.CmdPacketAuctionSearchByItemkey)
// 金币寄售 has its own 金币分类 request on the same opcode. Its exact body
// is uncaptured (see the gold protocol file); only mode 1 is diverted, and
// the reply keeps the item panel's convention of answering on op186.
if len(body) > 0 && body[0] == currentAuctionPayTypeConsignment {
return s.searchCurrentGoldConsignCategoryLocked(session, responseOpcode, body)
}
// Current NoPack.exe sends a fixed 20-byte category/filter request. Live
// captures 2026-09-27 (packet_log.txt, conn game-000168/game-000127): the
// selected tree node's u16 code sits at body[5:7] - 10000 武器, 11000 防具,
// 12000 首饰, 32000 特殊装备, and a leaf such as 10103 for
// 武器 > 鬼剑士/黑暗武士/守护者 > 太刀. Until this pass every one of them was
// answered with the same whole-market snapshot, which is why a listed
// bracelet appeared in every category. The category is now honoured through
// currentAuctionCategoryEligible; an unknown code keeps the old no-filter
// behaviour so a client this build has never seen still gets a list.
if len(body) != 20 || body[0] != currentAuctionPayTypeGold {
return s.sendGameUpperFailure(session, responseOpcode, currentAuctionFailureCode)
}
page := binary.LittleEndian.Uint32(body[1:5])
category := currentAuctionCategoryRequestCode(body)
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, responseOpcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
listings, err := owner.Search(ctx, nil)
if err != nil {
return s.sendGameUpperFailure(session, responseOpcode, currentAuctionFailureCode)
}
eligible := func(itemID uint32) bool {
return s.currentAuctionItemEligible(itemID) && s.currentAuctionCategoryEligible(category, itemID)
}
bodyOut := buildCurrentAuctionSearchBodyPage(listings, eligible, page, 100)
// A code the client sends but this build cannot place is worth a log line: it is
// either a newer client tree or a node whose membership rule is still unproven.
if _, found := currentAuctionCategoryNodeFor(category); !found {
s.logGameEvent(session, "game-auction-category-unmapped", "category", category, "page", page)
} else if currentAuctionCategoryIsUnpinned(category) {
s.logGameEvent(session, "game-auction-category-unpinned", "category", category, "page", page)
}
return s.sendGameUpperSuccess(session, responseOpcode, bodyOut)
}
func (s *Service) sendCurrentAuctionAveragePriceLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionAskAveragePrice)
// 金币寄售 shares op182 with the item auction but is a different request
// shape: 6 bytes with mode 1, against 8 bytes with mode 0. Only that exact
// combination is diverted here, so every request the item panel can produce
// still reaches the code below byte for byte.
if len(body) == currentAuctionGoldAveragePriceRequestSize &&
body[0] == currentAuctionPayTypeConsignment {
return s.sendCurrentAuctionGoldAveragePriceLocked(session, opcode, body)
}
if len(body) != 8 || body[0] != currentAuctionPayTypeGold {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
// Live current-client request: pay@0, itemID@1..4, reserved@5..7.
// Captures for items 24006 and 10081 both use this exact offset.
itemID := binary.LittleEndian.Uint32(body[1:5])
if itemID == 0 {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
listings, err := owner.Search(ctx, []uint32{itemID})
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
// The current reader consumes pay + one scalar + an auxiliary count,
// followed by SIX unit prices and SIX matching quantities. Those twelve
// slots are not history: the 上架 dialog's 「拍卖行最低价(以一口价为准)」 list is
// drawn straight from them.
//
// FUN_01d1d720 (class-1 op182) reads {route u8, u32 a, u32 b, u32 x6, u32 x6}
// then calls FUN_0300e2f0(a, prices, counts) for route 0
// (-> FUN_03007d00(a) + FUN_0300a520(prices, counts)).
// FUN_0300b190 is that list's row painter. It walks the two parallel int
// vectors the filler stored on the panel, steps 0xf per row, and prints
// each pair as dstr 46422 「%d个」 plus dstr 46252+60160 「每个 %s 金币」;
// a slot whose price or quantity is < 1 prints dstr 46450 「暂无拍卖品」.
//
// Live 2026-09-21T11:43Z, one listing of 3037 x2000 at unit price 100: the
// six slots were all the same average and the six counts all the same stack,
// so the dialog showed SIX identical rows for a market that had exactly one
// offer. The twelve slots now carry the real ladder instead.
var count, total int64
for _, listing := range listings {
if listing.Item.Count <= 0 || listing.UnitPrice <= 0 || listing.UnitPrice > math.MaxInt64/listing.Item.Count {
continue
}
listingTotal := listing.UnitPrice * listing.Item.Count
if total > math.MaxInt64-listingTotal {
continue
}
count += listing.Item.Count
total += listingTotal
}
var average uint32
if count > 0 {
average = currentAuctionUint32(total / count)
} else {
// The native registration dialog does not complete its price controls
// when the first scalar is zero. With no live listings, use the active
// PVF's own positive item price as a reference value; the ladder stays
// empty, so this is not fabricated market history. This also makes a
// brand-new tradeable item listable.
average = s.currentAuctionPVFReferencePrice(itemID)
}
ladder := currentAuctionPriceLadder(listings, currentAuctionPriceLadderSlots)
var response packetWriter
response.writeByte(currentAuctionPayTypeGold)
response.writeUint32(average)
response.writeUint32(currentAuctionUint32(count))
for index := 0; index < currentAuctionPriceLadderSlots; index++ {
response.writeUint32(ladder[index].unitPrice)
}
for index := 0; index < currentAuctionPriceLadderSlots; index++ {
response.writeUint32(ladder[index].quantity)
}
return s.sendGameUpperSuccess(session, opcode, response.bytes())
}
// currentAuctionPriceLadderSlots is how many (unit price, quantity) rungs the
// client's op182 reader always takes: two fixed groups of six.
const currentAuctionPriceLadderSlots = 6
// currentAuctionPriceRung is one row of the 上架 dialog's price list.
type currentAuctionPriceRung struct {
unitPrice uint32
quantity uint32
}
// currentAuctionPriceLadder is the cheapest-first view of what is actually on
// sale for one item: one rung per distinct unit price, with the total units
// offered at it. Rungs the market has no data for stay (0, 0), which the client
// renders as 「暂无拍卖品」 - the honest answer for a thin market, and the reason
// a single listing shows a single row instead of six copies of itself.
//
// Listings sharing a price are merged rather than repeated, because the list is
// labelled 最低价 (the lowest prices), not "the sellers".
func currentAuctionPriceLadder(listings []dnfauction.Listing, slots int) []currentAuctionPriceRung {
ladder := make([]currentAuctionPriceRung, slots)
if slots <= 0 {
return ladder
}
byPrice := make(map[int64]int64, len(listings))
for _, listing := range listings {
if listing.UnitPrice <= 0 || listing.Item.Count <= 0 {
continue
}
byPrice[listing.UnitPrice] += listing.Item.Count
}
prices := make([]int64, 0, len(byPrice))
for price := range byPrice {
prices = append(prices, price)
}
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
for index, price := range prices {
if index >= slots {
break
}
ladder[index] = currentAuctionPriceRung{
unitPrice: currentAuctionUint32(price),
quantity: currentAuctionUint32(byPrice[price]),
}
}
return ladder
}
// sendCurrentAuctionGoldAveragePriceLocked now lives in
// auction_gold_consignment.go, where it reports the ledger's real rolling
// average instead of a hardcoded zero. Its request size constant stays here
// because the op182 branch above is part of the item handler.
func (s *Service) currentAuctionPVFReferencePrice(itemID uint32) uint32 {
if s == nil || itemID == 0 {
return 0
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return 0
}
definition, err := catalog.ResolveItem(itemID)
if err != nil ||
(definition.Kind != dungeonDropItemStackable && definition.Kind != dungeonDropItemEquipment) ||
!currentDungeonDiscardTradeable(definition) ||
(definition.AuctionRuleFound && !definition.AuctionAllowed) {
return 0
}
if definition.PriceFound && definition.Price > 0 {
return currentAuctionUint32(definition.Price)
}
// Some current-PVF tradeable enchant beads and materials intentionally
// carry no shop price. The current auction dialog treats a zero first
// scalar as "price discovery still pending" even when all six history
// counts are zero. Use the protocol's smallest valid unit price only as the
// no-history control bound; it is not emitted as a historical sale.
switch definition.Kind {
case dungeonDropItemStackable:
return 1
case dungeonDropItemEquipment:
switch definition.Rarity {
case 0, 1, 2, 6:
return 1
}
}
return 0
}
func decodeCurrentAuctionSearchBody(body []byte, mode byte) (uint32, []uint32, bool) {
if len(body) < 10 || body[0] != mode {
return 0, nil, false
}
page := binary.LittleEndian.Uint32(body[1:5])
count := int(body[7])
end := 10 + count*4
if count == 0 || end > len(body) || len(body)-end > 8 {
return 0, nil, false
}
items := make([]uint32, count)
for index := range items {
items[index] = binary.LittleEndian.Uint32(body[10+index*4 : 14+index*4])
if items[index] == 0 {
return 0, nil, false
}
}
return page, items, true
}
// decodeCurrentAuctionSearchItemIDs is the item-auction entry point for the
// shape above. It keeps its own name and signature so every item call site is
// untouched; the gold panel calls decodeCurrentAuctionSearchBody with mode 1.
func decodeCurrentAuctionSearchItemIDs(body []byte) (uint32, []uint32, bool) {
return decodeCurrentAuctionSearchBody(body, currentAuctionPayTypeGold)
}
func buildCurrentAuctionSearchBody(listings []dnfauction.Listing, stackable func(uint32) bool) []byte {
return buildCurrentAuctionSearchBodyPage(listings, stackable, 0, math.MaxUint16)
}
func buildCurrentAuctionSearchBodyPage(listings []dnfauction.Listing, eligible func(uint32) bool, page uint32, pageSize int) []byte {
rows := make([]dnfauction.Listing, 0, len(listings))
for _, listing := range listings {
if listing.Item.ItemID <= 0 || listing.Item.ItemID > math.MaxUint32 || eligible == nil || !eligible(uint32(listing.Item.ItemID)) {
continue
}
rows = append(rows, listing)
}
total := len(rows)
if pageSize <= 0 || pageSize > math.MaxUint16 {
pageSize = math.MaxUint16
}
start64 := uint64(page) * uint64(pageSize)
if start64 >= uint64(len(rows)) {
rows = nil
} else {
start := int(start64)
end := min(len(rows), start+pageSize)
rows = rows[start:end]
}
var response packetWriter
response.writeByte(currentAuctionPayTypeGold)
response.writeUint32(uint32(min(total, math.MaxUint32)))
response.writeUint16(uint16(len(rows)))
for _, listing := range rows {
response.writeBytes(buildCurrentAuctionSearchRow(listing))
}
return response.bytes()
}
func (s *Service) currentAuctionStackableItem(itemID uint32) bool {
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return false
}
definition, err := catalog.ResolveItem(itemID)
return err == nil && definition.Kind == dungeonDropItemStackable
}
func (s *Service) currentAuctionItemEligible(itemID uint32) bool {
if s != nil && s.auctionItemEligibility != nil {
return s.auctionItemEligibility(itemID)
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return false
}
definition, err := catalog.ResolveItem(itemID)
if err != nil || (definition.Kind != dungeonDropItemStackable && definition.Kind != dungeonDropItemEquipment) ||
!currentDungeonDiscardTradeable(definition) {
return false
}
return !definition.AuctionRuleFound || definition.AuctionAllowed
}
// currentAuctionRegistrationPolicy applies the native market categories at
// the source-row boundary. The `[attach type]` whitelist is shared with the
// private store and the dungeon discard path (only `[free]` and `[sealing]`),
// stackables must be unbound, `[sealing]` equipment must be sealed, and rarity
// plays no part. The escrow owner rechecks ExpectedBind transactionally.
func (s *Service) currentAuctionRegistrationPolicy(itemID uint32, stack dnfrepo.ItemStack) (expectedBind bool, allowSealed bool, eligible bool) {
if s == nil || itemID == 0 || stack.ItemID != int64(itemID) || stack.Count <= 0 ||
currentDungeonDiscardStackLocked(stack) || currentDungeonDiscardStackExpired(stack, s.gameplayNow().UTC()) {
return false, false, false
}
if s.auctionItemEligibility != nil {
return stack.Bind, false, s.auctionItemEligibility(itemID) && !stack.Bind
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return false, false, false
}
definition, err := catalog.ResolveItem(itemID)
if err != nil {
return false, false, false
}
return currentAuctionRegistrationPolicyForDefinition(definition, stack)
}
func currentAuctionRegistrationPolicyForDefinition(definition dungeonDropItemDefinition, stack dnfrepo.ItemStack) (expectedBind bool, allowSealed bool, eligible bool) {
if stack.ItemID != int64(definition.ItemID) || stack.Count <= 0 ||
!currentDungeonDiscardTradeable(definition) ||
(definition.AuctionRuleFound && !definition.AuctionAllowed) {
return false, false, false
}
switch definition.Kind {
case dungeonDropItemStackable:
return stack.Bind, false, !stack.Bind
case dungeonDropItemEquipment:
if stack.Count != 1 {
return false, false, false
}
// `[attach type]` 白名单已在上面判过;`[sealing]` 再要求封装态,
// `[free]` 不看封装状态。品级 [rarity] 不参与判断。
if currentDungeonDiscardSealRequired(definition) && !currentDungeonDiscardSealed(stack) {
return false, false, false
}
// ExpectedBind/AllowSealed 冻结的是 escrow owner 事务里回查的 stack.Bind
// (owner.go: `stack.Bind != cmd.ExpectedBind` 与 `stack.Bind && !cmd.AllowSealed`),
// 所以必须回传 Bind 本身,不能拿封装位顶替 —— 否则「已封装但
// bind_flag=0」的装备会在托管那一步被 ErrItemUnavailable 打回。
return stack.Bind, stack.Bind, true
default:
return false, false, false
}
}
// buildCurrentAuctionSearchRow renders one 0xA8-byte op186 row. Its three price
// slots are, in order, the current bid total, the buyout total and the buyout
// unit price - see currentAuctionBidTotalPrice for why the first two are not
// the same value.
func buildCurrentAuctionSearchRow(listing dnfauction.Listing) []byte {
row := make([]byte, 0xA8)
binary.LittleEndian.PutUint64(row[0x00:0x08], listing.ID)
binary.LittleEndian.PutUint32(row[0x09:0x0D], currentAuctionBidTotalPrice(listing))
binary.LittleEndian.PutUint32(row[0x0D:0x11], currentAuctionBuyoutTotalPrice(listing))
binary.LittleEndian.PutUint32(row[0x11:0x15], currentAuctionBuyoutUnitPrice(listing))
writeCurrentAuctionName(row[0x15:0x2A], listing.SellerName)
row[0x2A] = currentAuctionRemainingHours(listing, time.Now().UTC())
copy(row[0x2B:0x7E], buildCurrentAuctionItem83(listing.Item))
// row[0xA0] is the seller-warning tier, not a duration: the current EXE
// row painter (sub_2076540) feeds it to sub_20743D0, which renders text
// 21671/21672/21673 - 注意!赖皮小人!/无耻之徒!/全民公敌! - as a red
// second line under the item name for 100/200/300, and asserts below 100.
// We have no such judgement to publish, so the slot stays 0 and the client
// skips that line. 期限 is row[0x2A], written above.
return row
}
func buildCurrentAuctionOwnedBody(listings []dnfauction.Listing, sellerID string, stackable func(uint32) bool) []byte {
rows := make([]dnfauction.Listing, 0, len(listings))
for _, listing := range listings {
if listing.SellerID != sellerID || listing.Item.ItemID <= 0 || listing.Item.ItemID > math.MaxUint32 || stackable == nil || !stackable(uint32(listing.Item.ItemID)) {
continue
}
rows = append(rows, listing)
if len(rows) == math.MaxUint8 {
break
}
}
var response packetWriter
response.writeByte(currentAuctionPayTypeGold)
response.writeByte(byte(len(rows)))
now := time.Now().UTC()
for _, listing := range rows {
// Same three price slots as an op186 row, in the same order, only with
// the unit price moved to the end of the entry.
row := make([]byte, 0x97)
binary.LittleEndian.PutUint64(row[0x00:0x08], listing.ID)
binary.LittleEndian.PutUint32(row[0x08:0x0C], currentAuctionBidTotalPrice(listing))
binary.LittleEndian.PutUint32(row[0x0C:0x10], currentAuctionBuyoutTotalPrice(listing))
writeCurrentAuctionName(row[0x10:0x1D], listing.SellerName)
// 期限: the same one-byte hour count the op186 row carries at +0x2A.
// The op188 row puts it one byte lower because its name field is 13
// bytes instead of 21; both land on struct+0x2A in the shared 0xA8 row
// the client builds for the painter.
row[0x1D] = currentAuctionRemainingHours(listing, now)
copy(row[0x1E:0x71], buildCurrentAuctionItem83(listing.Item))
binary.LittleEndian.PutUint32(row[0x93:0x97], currentAuctionBuyoutUnitPrice(listing))
response.writeBytes(row)
}
return response.bytes()
}
// buildCurrentAuctionBiddingBody renders the 我的竞拍 tab: `u8 payType |
// u16 count | count x 0x9F rows`, listing every live consignment this
// character currently holds the top bid on.
//
// The row layout is the gold panel's own, read off sub_3006310's mode-2 branch
// (the list sub_2FFAC60 hands back, panel+0x5A0, which op189 fills through
// sub_3011630): id at +0x00, the standing bid at +0x08, the buyout at +0x0C, a
// 13-byte NUL-terminated seller name at +0x10, the 期限 hour byte at +0x25
// (`mov al, [ebx+0x25]`), the 83-byte item at +0x26, the 30-byte jewel block at
// +0x79, the auxiliary colour at +0x97 and the buyout unit price last at +0x9B.
// Bytes 0x1D..0x25 are never read by the renderer and go out zero.
func buildCurrentAuctionBiddingBody(listings []dnfauction.Listing, bidderID string, eligible func(uint32) bool, now time.Time) []byte {
rows := make([]dnfauction.Listing, 0, len(listings))
for _, listing := range listings {
if listing.BidTotal <= 0 || listing.BidderID != bidderID ||
listing.Item.ItemID <= 0 || listing.Item.ItemID > math.MaxUint32 ||
eligible == nil || !eligible(uint32(listing.Item.ItemID)) {
continue
}
rows = append(rows, listing)
// The count is a u16 on the wire but sub_3011630 ingests
// `movzx edi, byte ptr [ebp+8]`, so anything past 255 would be read
// off the wire and then dropped. Stop at what the client can hold.
if len(rows) == math.MaxUint8 {
break
}
}
var response packetWriter
response.writeByte(currentAuctionPayTypeGold)
response.writeUint16(uint16(len(rows)))
for _, listing := range rows {
row := make([]byte, 0x9F)
binary.LittleEndian.PutUint64(row[0x00:0x08], listing.ID)
binary.LittleEndian.PutUint32(row[0x08:0x0C], currentAuctionBidTotalPrice(listing))
binary.LittleEndian.PutUint32(row[0x0C:0x10], currentAuctionBuyoutTotalPrice(listing))
writeCurrentAuctionName(row[0x10:0x1D], listing.SellerName)
row[0x25] = currentAuctionRemainingHours(listing, now)
copy(row[0x26:0x79], buildCurrentAuctionItem83(listing.Item))
binary.LittleEndian.PutUint32(row[0x9B:0x9F], currentAuctionBuyoutUnitPrice(listing))
response.writeBytes(row)
}
return response.bytes()
}
func buildCurrentAuctionItem83(stack dnfrepo.ItemStack) []byte {
item := make([]byte, 0x53)
if stack.Bind {
item[0] = 1
}
binary.LittleEndian.PutUint32(item[0x01:0x05], currentAuctionUint32(stack.ItemID))
binary.LittleEndian.PutUint32(item[0x06:0x0A], currentAuctionUint32(stack.Count))
raw := stack.RawEntry
if len(raw) == currentItemListEntryWireSize {
if raw[0x0D] != 0 {
item[0] = 1
}
item[0x05] = raw[0x0A]
copy(item[0x0A:0x0C], raw[0x0B:0x0D])
copy(item[0x0C:0x10], raw[0x0E:0x12])
item[0x10] = raw[0x13]
copy(item[0x11:0x13], raw[0x14:0x16])
item[0x33] = raw[0x57] & 0x1F
copy(item[0x36:0x3A], raw[0x38:0x3C])
item[0x3D] = raw[0x12]
item[0x3E] = raw[0x5D]
item[0x3F] = raw[0x58]
copy(item[0x41:0x43], raw[0x61:0x63])
copy(item[0x43:0x45], raw[0x63:0x65])
copy(item[0x45:0x49], raw[0x72:0x76])
item[0x49] = raw[0x76]
}
if !stack.ExpireAt.IsZero() {
binary.LittleEndian.PutUint32(item[0x36:0x3A], uint32(stack.ExpireAt.Unix()))
}
return item
}
// currentAuctionRemainingHours is the 期限 column both auction lists render.
// The current EXE row painter reads one byte - op186 row+0x2A, op188 row+0x1D -
// and formats it with text 60159 ("%d小时") into the column headed by text
// 60155 ("期限"), so the field is whole hours, not a tier code and not a
// timestamp. A listing that still has a partial hour left must not read 0, so
// the remainder rounds up; anything already past its deadline is 0 and the
// expiry sweep returns it.
func currentAuctionRemainingHours(listing dnfauction.Listing, now time.Time) byte {
if listing.ExpiresAt.IsZero() {
return 0
}
remaining := listing.ExpiresAt.Sub(now)
if remaining <= 0 {
return 0
}
hours := (remaining + time.Hour - 1) / time.Hour
if hours > math.MaxUint8 {
return math.MaxUint8
}
return byte(hours)
}
func currentAuctionUint32(value int64) uint32 {
if value <= 0 {
return 0
}
if value > math.MaxUint32 {
return math.MaxUint32
}
return uint32(value)
}
func currentAuctionTotalPrice(unitPrice, count int64) uint32 {
if unitPrice <= 0 || count <= 0 || unitPrice > math.MaxUint32/count {
return math.MaxUint32
}
return uint32(unitPrice * count)
}
// currentAuctionNoPrice is the "this price column does not exist" sentinel the
// current client's own row painters test. It is NOT zero.
//
// Every auction entry carries three price slots - current bid, buyout total,
// buyout unit - and each of them is rendered only when the slot is not -1:
//
// owner 7 (item auction), row painter FUN_02fffab0, row struct at widget+0x3c4:
// 02fffa36 CMP dword ptr [ESI + 0x3d1], -1 ; +0x0D = 一口价总额
// JZ -> the 60058 「一口价」 line is skipped entirely
// 02fffa7a CMP dword ptr [ESI + 0x3cd], -1 ; +0x09 = 当前竞拍价总额
// JZ -> the 60161 「当前竞拍」 line is skipped entirely
// owner 8 (金币寄售), row painter FUN_02076540, row struct at widget+0x3c8:
// 020768c9 CMP dword ptr [ESI + 0x3d1], -1 ; +0x09, same meaning
// 0207673e CMP dword ptr [ESI + 0x3d5], -1 ; +0x0D, same meaning
// and the row tooltip FUN_0207a760 tests the third slot the same way:
// 0207a84d CMP dword ptr [ECX + 0x3d9], -1 ; +0x11 = 一口价单价
//
// The unit slot doubles as the tooltip's "derive it yourself" flag: when it is
// -1 the tooltip divides the buyout total by the stack count to print
// dstr 46262 「每个: %s」, which is exactly how the 0x97 normalisation leaves it.
//
// So a listing that has no bidding (or no buyout) must publish -1 in the
// matching slot, not 0. A zero is a real price: it draws "当前竞拍 0 金币", and -
// as the live report showed - the buy path reads the bid slot as "a start price
// is set" and then refuses to sell fewer than the whole stack with dstr 46263
// 「已设定拍卖起始价的物品无法单个购买。」.
const currentAuctionNoPrice = uint32(0xFFFFFFFF)
// currentAuctionBidTotalPrice fills the 当前竞拍价 slot: the standing bid once
// somebody has bid, otherwise the seller's opening bid. Same term as 86JP's
// DisplayBidPrice, and the reason a bid has to show up here is that the client
// reads its own "must beat this" number out of this slot.
//
// A listing the seller registered buyout-only has no bidding at all, so the
// slot is the sentinel and the client hides the whole line. Leaving a number
// here is what made the buy dialog offer only the entire stack.
func currentAuctionBidTotalPrice(listing dnfauction.Listing) uint32 {
if !listing.HasBidding() && listing.BidTotal <= 0 {
return currentAuctionNoPrice
}
return currentAuctionUint32(listing.DisplayBidTotal())
}
// currentAuctionBuyoutTotalPrice and currentAuctionBuyoutUnitPrice fill the
// second and third price slots. A listing registered with an opening bid and no
// 一口价 has no buyout to show, so both go out as the sentinel and the client
// draws neither the 一口价 line nor a derived 每个 line. Every listing escrowed
// before the buyout became optional keeps a buyout equal to its unit price, so
// their rows are unchanged.
func currentAuctionBuyoutTotalPrice(listing dnfauction.Listing) uint32 {
if !listing.HasBuyout() {
return currentAuctionNoPrice
}
return currentAuctionTotalPrice(listing.BuyoutUnitPrice(), listing.Item.Count)
}
func currentAuctionBuyoutUnitPrice(listing dnfauction.Listing) uint32 {
if !listing.HasBuyout() {
return currentAuctionNoPrice
}
return currentAuctionUint32(listing.BuyoutUnitPrice())
}
func writeCurrentAuctionName(dst []byte, value string) {
encoded := rosterNameBytes(strings.TrimSpace(value))
if len(encoded) > len(dst) {
encoded = encoded[:len(dst)]
}
copy(dst, encoded)
}
func (s *Service) deliverCurrentAuctionPendingPayoutsLocked(ctx context.Context, owner *dnfauction.Owner, sellerID string) error {
payouts, err := owner.PendingPayouts(ctx, sellerID)
if err != nil {
return err
}
for _, payout := range payouts {
if err := s.settleCurrentAuctionPayoutLocked(ctx, owner, payout); err != nil {
return err
}
}
return nil
}
// settleCurrentAuctionPayoutLocked pays one gold outbox row. A released bid
// goes back to the wallet, everything else by mail - see
// dnfauction.Owner.RefundBidToBidder for why the two differ. A wallet credit
// that fails falls through to the mail so the gold is never stuck on one path.
func (s *Service) settleCurrentAuctionPayoutLocked(ctx context.Context, owner *dnfauction.Owner, payout dnfauction.Payout) error {
if !payout.Refund {
return s.deliverCurrentAuctionPayoutMailLocked(ctx, owner, payout)
}
goldAfter, err := owner.RefundBidToBidder(ctx, payout)
if err != nil {
s.logGameEvent(nil, "game-auction-bid-refund-wallet-failed",
"refund_id", payout.ID, "bidder_id", payout.SellerID, "gold", payout.Gold, "error", err)
return s.deliverCurrentAuctionPayoutMailLocked(ctx, owner, payout)
}
s.logGameEvent(nil, "game-auction-bid-refund-credited",
"refund_id", payout.ID, "bidder_id", payout.SellerID, "gold", payout.Gold, "gold_after", goldAfter)
// The bidder may be standing in the auction house watching their own
// wallet; without this their client keeps the pre-refund number until
// something else refreshes it.
if characterID, parseErr := strconv.ParseUint(payout.SellerID, 10, 16); parseErr == nil && characterID != 0 {
if bidderSession, online := s.onlineGameSession(uint16(characterID)); online {
if err := s.sendGameUpperRawClass(bidderSession, 14, buildCurrentGoldStateBody(goldAfter), 0); err != nil {
s.logGameEvent(nil, "game-auction-bid-refund-gold-refresh-failed",
"bidder_id", payout.SellerID, "error", err)
}
}
}
return nil
}
// settleCurrentAuctionWonListingsLocked closes every listing that ran out its
// duration with a bid standing on it and sends both halves - the item to the
// winner, the proceeds to the seller. Neither party has to be online: the rows
// are durable, so a mail failure only defers the delivery.
func (s *Service) settleCurrentAuctionWonListingsLocked(session *gameSession) {
if s == nil {
return
}
owner, err := s.currentAuctionOwner()
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := owner.SettleWonListings(ctx)
if err != nil {
s.logGameEvent(session, "game-auction-bid-settlement-deferred", "error", err)
return
}
for _, settlement := range result.Settlements {
s.logGameEvent(session, "game-auction-bid-won",
"listing_id", settlement.ListingID,
"winner_id", settlement.Delivery.BuyerID,
"seller_id", settlement.Payout.SellerID,
"item_id", settlement.Delivery.Item.ItemID,
"count", settlement.Delivery.Item.Count,
"proceeds", settlement.Payout.Gold)
buyerMailed := false
if deliveryErr := s.deliverCurrentAuctionBuyerMailLocked(ctx, owner, settlement.Delivery); deliveryErr != nil {
s.logGameEvent(session, "game-auction-bid-win-mail-deferred",
"delivery_id", settlement.Delivery.ID, "winner_id", settlement.Delivery.BuyerID, "error", deliveryErr)
} else {
buyerMailed = true
}
payoutMailed := false
if payoutErr := s.deliverCurrentAuctionPayoutMailLocked(ctx, owner, settlement.Payout); payoutErr != nil {
s.logGameEvent(session, "game-auction-bid-payout-mail-deferred",
"payout_id", settlement.Payout.ID, "seller_id", settlement.Payout.SellerID, "error", payoutErr)
} else {
payoutMailed = true
}
// 竞拍成交记录:SettleWonListings 只返回**本次真的结算掉**的那些挂单
// (已经写过 delivery/payout 行的行会被它跳过,结算完还会把挂单行删掉),
// 所以这份列表本身就是"这一次的跃迁集合",重复跑这个扫描拿到的是空列表,
// 不会重复记。这正是每个拍卖命令开头都能安全调用它的原因。
//
// 没有 quantity/unit_price 这类字段:挂单行在结算时已经被删除,
// 结算结果里只带得走成交价与件数,缺的就留空,不为凑字段回查数据库。
won := map[string]any{
"listing_id": settlement.ListingID,
"item_id": settlement.Delivery.Item.ItemID,
"item_name": s.currentAuctionItemDisplayName(settlement.Delivery.Item.ItemID),
"item_count": settlement.Delivery.Item.Count,
"buyout": false,
"whole_listing": true,
"buyer_id": strings.TrimSpace(settlement.Delivery.BuyerID),
"seller_id": strings.TrimSpace(settlement.Payout.SellerID),
"seller_name": strings.TrimSpace(settlement.Payout.SellerName),
"seller_proceeds": settlement.Payout.Gold,
"buyer_mailed": buyerMailed,
"delivered": payoutMailed,
}
if settlement.Payout.HasStatement {
won["sale_price"] = settlement.Payout.Statement.GrossGold
won["fee_gold"] = settlement.Payout.Statement.FeeGold
won["deposit_gold"] = settlement.Payout.Statement.DepositGold
}
s.recordAuction(session, "won", won)
// 卖家收益记录与竞拍成交记录成对出现:成交那一行回答"东西卖给了谁、
// 卖了多少钱",这一行回答"钱怎么到了卖家手里"。Payout 行同样是本次
// 新建的,延后重发的轮次不记。
s.recordAuction(session, "payout", auctionPayoutRecordFields(s, settlement.Payout, payoutMailed))
}
}
func (s *Service) retryCurrentAuctionMailsLocked(session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
owner, err := s.currentAuctionOwner()
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
characterID := selectedCharacterIDString(session)
if err := s.deliverCurrentAuctionPendingBuyerMailsLocked(ctx, owner, characterID); err != nil {
s.logGameEvent(session, "game-auction-buyer-mail-retry-deferred", "buyer_id", characterID, "error", err)
}
if err := s.deliverCurrentAuctionPendingPayoutsLocked(ctx, owner, characterID); err != nil {
s.logGameEvent(session, "game-auction-payout-mail-retry-deferred", "seller_id", characterID, "error", err)
}
}
func (s *Service) deliverCurrentAuctionPendingBuyerMailsLocked(ctx context.Context, owner *dnfauction.Owner, buyerID string) error {
deliveries, err := owner.PendingDeliveries(ctx, buyerID)
if err != nil {
return err
}
for _, delivery := range deliveries {
// The outbox carries purchases, cancelled consignments and expired
// ones; a deferred row must be re-sent with the wording it was queued
// under.
deliver := s.deliverCurrentAuctionBuyerMailLocked
switch {
case delivery.Expired:
deliver = s.deliverCurrentAuctionExpiredMailLocked
case delivery.Return:
deliver = s.deliverCurrentAuctionReturnMailLocked
}
if err := deliver(ctx, owner, delivery); err != nil {
return err
}
}
return nil
}
func (s *Service) deliverCurrentAuctionBuyerMailLocked(ctx context.Context, owner *dnfauction.Owner, delivery dnfauction.Delivery) error {
return s.deliverCurrentAuctionItemMailLocked(ctx, owner, delivery, currentAuctionPurchaseMail)
}
// deliverCurrentAuctionReturnMailLocked hands a cancelled consignment back to
// its seller through the same durable outbox a purchase uses, so a full bag
// can no longer strand the item in escrow and the seller sees the ordinary
// mailbox alarm instead of a row that only appears after reselecting.
func (s *Service) deliverCurrentAuctionReturnMailLocked(ctx context.Context, owner *dnfauction.Owner, delivery dnfauction.Delivery) error {
return s.deliverCurrentAuctionItemMailLocked(ctx, owner, delivery, currentAuctionReturnMail)
}
// deliverCurrentAuctionExpiredMailLocked is the same return, worded for a
// listing that ran out its duration rather than one the seller pulled.
func (s *Service) deliverCurrentAuctionExpiredMailLocked(ctx context.Context, owner *dnfauction.Owner, delivery dnfauction.Delivery) error {
return s.deliverCurrentAuctionItemMailLocked(ctx, owner, delivery, currentAuctionExpiredMail)
}
type currentAuctionItemMailKind struct {
title string
body func(itemName string) string
source string
// noticeKind is the class0/0xA9 banner this delivery owes the recipient.
//
// Every delivery used to be announced as kind 2 (dstr 60088
// 「您在拍卖行竞拍的[%s]已拍下,请确认邮件」), which is only true for a
// purchase. A return delivery carries the SELLER in BuyerID, so a cancelled
// listing told the seller they had *won* their own auction - the wrong-text
// complaint. FUN_01d80150's switch has an exact text for each case:
// 0 60086 竞拍中出现更高价格,金额返还 outbid / released bid
// 1 60087 上架的[%s]已被拍下 sale payout to the seller
// 2 60088 竞拍的[%s]已拍下 purchase to the buyer
// 3 60089 上架的[%s]竞拍失败 expiry (流拍) back to the seller
// 4 60090 [%s]的拍卖已被取消,金额返还 cancel releasing a bid
// 5 60091 [%s]的竞拍已被取消 cancel returning the item
noticeKind byte
}
var (
currentAuctionPurchaseMail = currentAuctionItemMailKind{
title: "拍卖行购买物品",
body: func(itemName string) string { return fmt.Sprintf("您购买的%s已通过邮件送达。", itemName) },
source: "auction_purchase_delivery",
// The buyer won, so 60088.
noticeKind: currentAuctionNoticeKindBuyerWon,
}
currentAuctionReturnMail = currentAuctionItemMailKind{
title: "拍卖行取消上架",
body: func(itemName string) string {
return fmt.Sprintf("您取消上架的%s已通过邮件返还。", itemName)
},
source: "auction_listing_return",
// The seller pulled the listing, so 60091
// 「[%s]的竞拍已被取消,请确认邮件」.
noticeKind: currentAuctionNoticeKindCancelBid,
}
currentAuctionExpiredMail = currentAuctionItemMailKind{
title: "拍卖行流拍返还",
body: func(itemName string) string {
return fmt.Sprintf("您上架的%s已到期流拍,通过邮件返还。", itemName)
},
source: "auction_listing_expired",
// Nobody bought it, so 60089 「上架的[%s]竞拍失败」 - the kind the
// notice table listed as unused until this path claimed it.
noticeKind: currentAuctionNoticeKindSellerFailed,
}
)
func (s *Service) deliverCurrentAuctionItemMailLocked(ctx context.Context, owner *dnfauction.Owner, delivery dnfauction.Delivery, kind currentAuctionItemMailKind) error {
if owner == nil || delivery.ID == "" || delivery.BuyerID == "" || delivery.ListingID == 0 || delivery.Item.ItemID <= 0 || delivery.Item.Count <= 0 {
return dnfauction.ErrInvalidRequest
}
repositories, ok := s.repositoryGroup()
if !ok {
return dnfauction.ErrUnavailable
}
itemName := s.currentAuctionItemDisplayName(delivery.Item.ItemID)
attachment := dnfrepo.MailAttachment{
ItemID: delivery.Item.ItemID,
Count: delivery.Item.Count,
Bind: delivery.Item.Bind,
ExpireAt: delivery.Item.ExpireAt,
RawEntry: append([]byte(nil), delivery.Item.RawEntry...),
Extra: cloneCurrentAuctionAttachmentExtra(delivery.Item.Extra),
}
_, err := dnfmail.NewOwner(repositories).SendSystem(ctx, dnfmail.SystemSendRequest{
RecipientCharacterID: delivery.BuyerID,
Title: kind.title,
Body: kind.body(itemName),
Gold: delivery.Gold,
Attachments: []dnfrepo.MailAttachment{attachment},
Source: kind.source,
CompletionKey: delivery.ID,
})
if err != nil {
return err
}
if err := owner.ConfirmDeliveryMail(ctx, dnfauction.ConfirmDeliveryCommand{
DeliveryID: delivery.ID,
BuyerID: delivery.BuyerID,
ListingID: delivery.ListingID,
ItemID: delivery.Item.ItemID,
Count: delivery.Item.Count,
Gold: delivery.Gold,
}); err != nil {
return err
}
if characterID, parseErr := strconv.ParseUint(delivery.BuyerID, 10, 16); parseErr == nil && characterID != 0 {
if err := s.sendMailboxAlarmToOnlineRecipient(uint16(characterID)); err != nil {
s.logGameEvent(nil, "game-auction-buyer-mail-alarm-failed", "buyer_id", delivery.BuyerID, "delivery_id", delivery.ID, "error", err)
}
}
// The recipient's banner. It is the delivery kind that decides the text:
// a purchase is 60088 (竞拍的[%s]已拍下), a cancel is 60091 (竞拍的[%s]已被取消)
// and an expiry is 60089 (上架的[%s]竞拍失败). Announcing all three as 60088 is
// what told a cancelling seller they had won their own auction.
s.notifyCurrentAuctionNoticeID(delivery.BuyerID, currentAuctionNoticeRouteItem, kind.noticeKind, delivery.Item.ItemID)
return nil
}
func (s *Service) deliverCurrentAuctionPayoutMailLocked(ctx context.Context, owner *dnfauction.Owner, payout dnfauction.Payout) error {
if owner == nil || payout.ID == "" || payout.SellerID == "" || payout.Gold <= 0 {
return dnfauction.ErrInvalidRequest
}
repositories, ok := s.repositoryGroup()
if !ok {
return dnfauction.ErrUnavailable
}
itemName := s.currentAuctionItemDisplayName(payout.ItemID)
// The same gold outbox carries sale proceeds to a seller and a released
// bid back to its bidder, so a deferred row has to be re-sent with the
// wording it was queued under - Refund is what remembers which.
title, body, source := "拍卖行成交款",
fmt.Sprintf("%s已成交%d个,成交款%d金币。", itemName, payout.SoldCount, payout.Gold),
"auction_sale_proceeds"
if payout.Refund {
title, body, source = "拍卖行竞拍返还",
fmt.Sprintf("您对%s的竞拍已被更高的出价超越或该物品已结束,返还竞拍金额%d金币。", itemName, payout.Gold),
"auction_bid_refund"
} else if payout.HasStatement {
// A settled sale prints the itemised statement the seller's own client
// shows, so the three numbers behind the payout stay visible instead of
// only the net. The breakdown rides the outbox row, so a deferred retry
// prints the same letter after the listing row is gone.
title, body = currentAuctionSaleStatement(payout, itemName)
}
_, err := dnfmail.NewOwner(repositories).SendSystem(ctx, dnfmail.SystemSendRequest{
RecipientCharacterID: payout.SellerID,
Title: title,
Body: body,
Gold: payout.Gold,
Source: source,
CompletionKey: payout.ID,
})
if err != nil {
return err
}
if err := owner.ConfirmPayoutMail(ctx, dnfauction.ConfirmPayoutCommand{PayoutID: payout.ID, SellerID: payout.SellerID, Gold: payout.Gold}); err != nil {
return err
}
if characterID, parseErr := strconv.ParseUint(payout.SellerID, 10, 16); parseErr == nil && characterID != 0 {
if err := s.sendMailboxAlarmToOnlineRecipient(uint16(characterID)); err != nil {
s.logGameEvent(nil, "game-auction-sale-mail-alarm-failed", "seller_id", payout.SellerID, "payout_id", payout.ID, "error", err)
}
}
// The seller's banner. A settled sale is kind 1 (dstr 60087
// 「您在拍卖行上架的[%s]已被拍下,请确认邮件」); a released bid rides the same
// outbox with Refund set and is kind 0 (dstr 60086).
noticeKind := currentAuctionNoticeKindSellerSold
if payout.Refund {
noticeKind = currentAuctionNoticeKindOutbid
}
s.notifyCurrentAuctionNoticeID(payout.SellerID, currentAuctionNoticeRouteItem, noticeKind, payout.ItemID)
return nil
}
// currentAuctionSaleStatement renders a settled sale as the itemised letter the
// seller's client shows. The line set and its wording are the operator's target
// format: what the item sold for, the deposit that came home with it, the
// commission taken out of it, and the net the letter pays.
//
// [已售出 金刚石 1个。]
// 出售价 : + 10000 金币
// 保证金 : + 10000 金币
// 手续费 : - 500 金币
// 已售出 金刚石 并获得 19500 金币。
//
// The first line is BOTH the letter's subject and the body's first line. The
// client's mail detail pane draws the body only - a subject alone is invisible
// there, which is what the live letter showed - while the mailbox list draws the
// subject, so carrying it in both places is what makes all five lines appear
// exactly as the target format has them.
//
// 保证金 appears only when a deposit actually came back - a partial sale keeps
// it riding the remaining pieces, and a listing registered before deposits
// existed never had one - and 手续费 only when a commission was charged, so a
// legacy fee-free listing does not print "- 0 金币".
func currentAuctionSaleStatement(payout dnfauction.Payout, itemName string) (string, string) {
statement := payout.Statement
lines := []string{fmt.Sprintf("[已售出 %s %d个。]", itemName, payout.SoldCount)}
lines = append(lines, fmt.Sprintf("出售价 : + %d 金币", statement.GrossGold))
if statement.DepositGold > 0 {
lines = append(lines, fmt.Sprintf("保证金 : + %d 金币", statement.DepositGold))
}
if statement.FeeGold > 0 {
lines = append(lines, fmt.Sprintf("手续费 : - %d 金币", statement.FeeGold))
}
lines = append(lines, fmt.Sprintf("已售出 %s 并获得 %d 金币。", itemName, payout.Gold))
return lines[0], strings.Join(lines, "\n")
}
func (s *Service) currentAuctionItemDisplayName(itemID int64) string {
fallback := fmt.Sprintf("物品%d", itemID)
if s == nil || itemID <= 0 || itemID > math.MaxUint32 {
return fallback
}
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return fallback
}
definition, err := catalog.ResolveItem(uint32(itemID))
if err != nil || strings.TrimSpace(definition.Name) == "" {
return fallback
}
return strings.TrimSpace(definition.Name)
}
func cloneCurrentAuctionAttachmentExtra(source map[string]string) map[string]string {
if len(source) == 0 {
return nil
}
result := make(map[string]string, len(source))
for key, value := range source {
result[key] = value
}
return result
}
func (s *Service) sendCurrentAuctionOwnedItemsLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionMyRegistedItemInfo)
// 金币寄售 has its own 我寄售的金币 tab on op188. It sends the same one-byte
// shape as the item tab but with mode 1. Live capture 2026-09-21T09:43:18Z,
// packet_log.txt: op188 body `01`, answered 00 99 00 - the third popup.
if len(body) == 1 && body[0] == currentAuctionPayTypeConsignment {
return s.sendCurrentAuctionGoldOwnedItemsLocked(session, opcode, body)
}
if len(body) != 1 || body[0] != currentAuctionPayTypeGold {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
if err := s.deliverCurrentAuctionPendingPayoutsLocked(ctx, owner, selectedCharacterIDString(session)); err != nil {
s.logGameEvent(session, "game-auction-payout-mail-retry-deferred", "seller_id", selectedCharacterIDString(session), "error", err)
}
claim, err := owner.ReturnExpiredListings(ctx, selectedCharacterIDString(session), strings.TrimSpace(s.accountIDForSession(session)))
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
for _, expired := range claim.ExpiredReturns {
if deliveryErr := s.deliverCurrentAuctionExpiredMailLocked(ctx, owner, expired); deliveryErr != nil {
s.logGameEvent(session, "game-auction-return-mail-deferred",
"delivery_id", expired.ID, "seller_id", expired.BuyerID,
"listing_id", expired.ListingID, "item_id", expired.Item.ItemID,
"reason", "expired", "error", deliveryErr)
}
}
listings, err := owner.Search(ctx, nil)
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
// Recover listings created by older builds that accepted equipment/avatar
// rows the current 83-byte projector cannot preserve. They must never stay
// invisible and uncancellable in escrow.
for _, listing := range listings {
if listing.SellerID != selectedCharacterIDString(session) ||
(listing.Item.ItemID > 0 && listing.Item.ItemID <= math.MaxUint32 && s.currentAuctionItemEligible(uint32(listing.Item.ItemID))) {
continue
}
returned, cancelErr := owner.Cancel(ctx, dnfauction.CancelCommand{
SellerID: listing.SellerID, AccountID: strings.TrimSpace(s.accountIDForSession(session)), ListingID: listing.ID,
})
if cancelErr != nil {
s.logGameEvent(session, "game-auction-unsupported-listing-return-deferred", "listing_id", listing.ID, "item_id", listing.Item.ItemID, "error", cancelErr)
continue
}
if returned.Delivery.ID != "" {
if deliveryErr := s.deliverCurrentAuctionReturnMailLocked(ctx, owner, returned.Delivery); deliveryErr != nil {
s.logGameEvent(session, "game-auction-return-mail-deferred",
"delivery_id", returned.Delivery.ID, "seller_id", returned.Delivery.BuyerID,
"listing_id", returned.Delivery.ListingID, "item_id", returned.Delivery.Item.ItemID,
"error", deliveryErr)
}
}
}
listings, err = owner.Search(ctx, nil)
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
return s.sendGameUpperSuccess(session, opcode, buildCurrentAuctionOwnedBody(listings, selectedCharacterIDString(session), s.currentAuctionItemEligible))
}
// sendCurrentAuctionGoldOwnedItemsLocked now lives in
// auction_gold_consignment.go, where it renders the ledger's real rows instead
// of an empty list. The op188 mode-1 branch above stays here.
// sendCurrentAuctionBiddingItemsLocked answers 我的竞拍. Anything this
// character has outbid, been outbid on, or already won has left the live
// listing set, so the tab is exactly "consignments I am currently winning".
func (s *Service) sendCurrentAuctionBiddingItemsLocked(session *gameSession, body []byte) error {
opcode := uint16(dnfenum.CmdPacketAuctionMyBiddingInfo)
if len(body) != 1 || body[0] != currentAuctionPayTypeGold {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
owner, err := s.currentAuctionOwner()
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
listings, err := owner.Search(ctx, nil)
if err != nil {
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
}
return s.sendGameUpperSuccess(session, opcode, buildCurrentAuctionBiddingBody(
listings, selectedCharacterIDString(session), s.currentAuctionItemEligible, time.Now().UTC()))
}
// sendCurrentAuctionInventorySlot republishes one source cell after a listing
// took its item out.
//
// The empty row must carry item id -1, not 0. The client's op14 list-0 reader
// sub_1D73120 only RemoveItem's when the id is exactly -1 (0x1D73912); anything
// else is an in-place update that leaves the old object alive, and the next row
// for that cell is then rejected by the 0x1D73AB2 type check because its id
// changed. The visible symptom is precisely the live report: an item that went
// out on a listing and came back by mail stays invisible in the bag until the
// player reselects the character, because the claim's op14 row never became a
// real "add". Same fix, same reason, as the 2026-09-11 装备再生 entry.
//
// Account-shared crystal/soul cells are the one exception and keep (0, 0): the
// right-hand crystal panel recognises a zero pair as "empty" and does not
// understand 0xFFFFFFFF (see sendSelectedIncrementalItemSlotRefreshes).
func (s *Service) sendCurrentAuctionInventorySlot(session *gameSession, listType byte, slot int16, inventory dnfrepo.InventoryRecord) error {
stack, exists := inventory.Slots[inventorySlotKey(listType, slot)]
var entry currentItemListEntry
switch {
case exists && stack.ItemID > 0:
entry = currentItemListEntryFromStack(listType, slot, stack)
case dnfrepo.IsAccountSharedInventorySlot(listType, slot):
entry.patchCore(slot, 0, 0)
default:
entry.patchCore(slot, math.MaxUint32, 0)
}
return s.sendGameUpperRawClass(session, 14, buildCurrentItemUpdateBody(listType, []currentItemListEntry{entry}), 0)
}
func inventorySlotKey(listType byte, slot int16) string {
return fmt.Sprintf("%d:%d", listType, slot)
}