DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,925 @@
// Package itemtrade owns the durable, two-character inventory exchange used
// by the current client's town item-trade flow. Wire/session state remains in
// dnfbridge; this package validates offers and commits both inventories.
package itemtrade
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/itemquality"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrRepositoryUnavailable = errors.New("item trade repository is unavailable")
ErrInvalidParticipants = errors.New("item trade participants are invalid")
ErrInvalidOffer = errors.New("item trade offer is invalid")
ErrItemUnavailable = errors.New("item trade item is unavailable")
ErrItemNotTradeable = errors.New("item is not tradeable")
ErrInventoryFull = errors.New("item trade destination inventory is full")
ErrGoldUnavailable = errors.New("item trade gold is unavailable")
ErrGoldOverflow = errors.New("item trade gold would overflow")
ErrReceiptInvalid = errors.New("item trade completion receipt is invalid")
)
const (
itemTradeReceiptVersion = int64(3)
itemTradeReceiptPrefix = "item_trade_completion_receipt_"
)
const (
mainListType byte = 0
avatarListType byte = 1
// currentItemRowSize is the fixed projected item row the current client
// reads; only a row of exactly this size keeps the equipment quality seed
// at +0x06.
currentItemRowSize = 0x77
equipmentFirst int16 = 9
equipmentLast int16 = 64
consumableFirst int16 = 65
consumableLast int16 = 120
materialFirst int16 = 121
materialLast int16 = 176
questFirst int16 = 177
questLast int16 = 232
professionFirst int16 = 233
professionLast int16 = 288
// 徽章页 289..351 是 9 行 × 7 格 = 63(与 dnfbridge 的 currentAvatarEmblemPageSlotStart/End
// 同一口径)。这里原先是 289..344,把最后一行 345..351 划给了 special 页;那会让
// 落在徽章页第 9 行的徽章交易不出去(destinationSlotRange 找不到所属页 → 直接拒收)。
emblemFirst int16 = 289
emblemLast int16 = 351
specialFirst int16 = 352
specialLast int16 = 353
avatarFirst int16 = 0
avatarLast int16 = 500
)
type Offer struct {
TradeSlot uint16
SourceList byte
SourceSlot int16
Count int64
ExpectedItem int64
ExpectedInstance uint32
ExpectedBind bool
AllowSealed bool
}
type Participant struct {
CharacterID string
// AccountID is only required when one of this participant's offers sources
// the account-owned crystal/soul warehouse (list-0 slots 354..365). That
// storage is not part of the character inventory record, so the exchange
// has to run inside the wider account/character transaction instead.
AccountID string
Offers []Offer
Gold int64
}
type Transfer struct {
FromCharacterID string
ToCharacterID string
TradeSlot uint16
DestinationList byte
DestinationSlot int16
Stack dnfrepo.ItemStack
}
type Result struct {
Inventories map[string]dnfrepo.InventoryRecord
Characters map[string]dnfrepo.CharacterRecord
Received map[string][]Transfer
Replayed bool
}
type Owner struct {
repositories dnfrepo.Group
now func() time.Time
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CharacterTrade == nil {
return nil, ErrRepositoryUnavailable
}
return &Owner{repositories: repositories, now: time.Now}, nil
}
func (o *Owner) Exchange(ctx context.Context, first Participant, second Participant) (Result, error) {
// 玩家对玩家的在线交易:收金币交易手续费、并累计小额交易次数。
return o.exchange(ctx, "", "", true, first, second)
}
// ExchangeWithCompletionKey performs the same atomic two-character exchange,
// but also records one durable receipt on receiptCharacterID. A caller may
// repeat the exact command after an indeterminate commit result; the second
// call returns the committed transfer without charging or moving the item a
// second time.
func (o *Owner) ExchangeWithCompletionKey(
ctx context.Context,
completionKey string,
receiptCharacterID string,
first Participant,
second Participant,
) (Result, error) {
completionKey = strings.TrimSpace(completionKey)
receiptCharacterID = strings.TrimSpace(receiptCharacterID)
if completionKey == "" || len(completionKey) > 256 || receiptCharacterID == "" {
return Result{}, ErrReceiptInvalid
}
// 个人商店(摆摊)收购不是玩家交易,不收交易手续费、也不计小额交易次数。
return o.exchange(ctx, completionKey, receiptCharacterID, false, first, second)
}
func (o *Owner) exchange(
ctx context.Context,
completionKey string,
receiptCharacterID string,
applyGoldTradeFee bool,
first Participant,
second Participant,
) (Result, error) {
if o == nil || o.repositories.CharacterTrade == nil {
return Result{}, ErrRepositoryUnavailable
}
first.CharacterID = strings.TrimSpace(first.CharacterID)
second.CharacterID = strings.TrimSpace(second.CharacterID)
if first.CharacterID == "" || second.CharacterID == "" || first.CharacterID == second.CharacterID {
return Result{}, ErrInvalidParticipants
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return Result{}, err
}
participants := []Participant{first, second}
if first.Gold < 0 || second.Gold < 0 {
return Result{}, ErrInvalidOffer
}
sort.Slice(participants, func(i, j int) bool {
return participants[i].CharacterID < participants[j].CharacterID
})
if completionKey != "" && receiptCharacterID != participants[0].CharacterID && receiptCharacterID != participants[1].CharacterID {
return Result{}, ErrReceiptInvalid
}
receiptKeyHash := sha256.Sum256([]byte(completionKey))
receiptAuthorityHash := itemTradeAuthorityHash(participants)
// An offer that sources the account-owned crystal/soul warehouse needs the
// account inventory inside the same transaction as both characters. Only
// then can the debit and the credit commit together.
accountShared := false
for _, participant := range participants {
for _, offer := range participant.Offers {
if dnfrepo.IsAccountSharedInventorySlot(offer.SourceList, offer.SourceSlot) {
accountShared = true
}
}
}
accountIDs := map[string]string{}
if accountShared {
if o.repositories.AccountPairTrade == nil {
return Result{}, ErrRepositoryUnavailable
}
// Each side keeps its own warehouse. A cross-account sale therefore
// locks two account records plus both characters; a same-account one
// resolves to a single shared record.
for _, participant := range participants {
id := strings.TrimSpace(participant.AccountID)
if id == "" {
return Result{}, ErrItemNotTradeable
}
accountIDs[participant.CharacterID] = id
}
}
result := Result{}
commit := func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
accountInventories dnfrepo.AccountInventoryRepository,
) error {
accountRecords := make(map[string]dnfrepo.AccountInventoryRecord, 2)
if accountInventories != nil {
for _, accountID := range accountIDs {
if _, already := accountRecords[accountID]; already {
continue
}
record, found, err := accountInventories.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
record = dnfrepo.AccountInventoryRecord{AccountID: accountID}
}
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
accountRecords[accountID] = record
}
}
loaded := make(map[string]dnfrepo.InventoryRecord, 2)
for _, participant := range participants {
record, found, err := inventories.Load(ctx, participant.CharacterID)
if err != nil {
return err
}
if !found {
record = dnfrepo.InventoryRecord{CharacterID: participant.CharacterID}
}
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
loaded[participant.CharacterID] = record
}
loadedCharacters := make(map[string]dnfrepo.CharacterRecord, 2)
if participants[0].Gold > 0 || participants[1].Gold > 0 || completionKey != "" {
for _, participant := range participants {
record, found, err := characters.Load(ctx, participant.CharacterID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("character %s: %w", participant.CharacterID, ErrGoldUnavailable)
}
if record.Stats == nil {
record.Stats = make(map[string]int64)
}
loadedCharacters[participant.CharacterID] = record
}
if completionKey != "" {
receiptOwner := loadedCharacters[receiptCharacterID]
replayed, replayResult, err := replayItemTradeReceipt(
receiptOwner,
participants,
loadedCharacters,
loaded,
receiptKeyHash,
receiptAuthorityHash,
)
if err != nil {
return err
}
if replayed {
result = replayResult
return nil
}
}
// 金币结算:基础转移 + 交易手续费 + 小额交易次数累计。
// 手续费必须和钱包变更落在同一笔事务里,所以先算出每个参与者
// 的净变化量,再统一写回 loadedCharacters(结尾的
// characters.Save 会把它和物品一起提交)。
deltas := currentItemTradeGoldDeltas(participants, loadedCharacters, o.clock(), applyGoldTradeFee)
for _, participant := range participants {
record := loadedCharacters[participant.CharacterID]
balance := record.Stats["gold"]
delta := deltas[participant.CharacterID]
if delta < 0 && balance < -delta {
return fmt.Errorf("character %s balance=%d required=%d: %w", participant.CharacterID, balance, -delta, ErrGoldUnavailable)
}
if delta > 0 && balance > math.MaxInt64-delta {
return fmt.Errorf("character %s balance=%d incoming=%d: %w", participant.CharacterID, balance, delta, ErrGoldOverflow)
}
record.Stats["gold"] = balance + delta
loadedCharacters[participant.CharacterID] = record
}
}
// Validation reads the same list-0 view the client was shown, which
// overlays the account warehouse on top of the character record.
viewFor := func(characterID string) dnfrepo.InventoryRecord {
if accountInventories == nil {
return loaded[characterID]
}
return dnfrepo.MergeAccountSharedInventory(loaded[characterID], accountRecords[accountIDs[characterID]])
}
staged := make(map[string][]stagedOffer, 2)
for _, participant := range participants {
rows, err := validateOffers(viewFor(participant.CharacterID), participant.Offers, o.clock())
if err != nil {
return fmt.Errorf("character %s: %w", participant.CharacterID, err)
}
staged[participant.CharacterID] = rows
}
// Remove every outgoing quantity before assigning incoming slots. This
// lets a full inventory complete a one-for-one exchange without a
// transient capacity failure.
for _, participant := range participants {
record := loaded[participant.CharacterID]
for _, row := range staged[participant.CharacterID] {
slots := record.Slots
if row.accountShared {
slots = accountRecords[accountIDs[participant.CharacterID]].Slots
}
stack := slots[row.sourceKey]
if row.offer.Count == stack.Count {
delete(slots, row.sourceKey)
} else {
stack.Count -= row.offer.Count
slots[row.sourceKey] = stack
}
}
loaded[participant.CharacterID] = record
}
received := make(map[string][]Transfer, 2)
for index, participant := range participants {
recipient := participants[1-index]
record := loaded[recipient.CharacterID]
for _, row := range staged[participant.CharacterID] {
// A cube/soul family is pinned to one warehouse cell in the
// current client, so an account-owned row can only be received
// back into that same cell - stacked onto whatever is already
// there rather than allocated a free bag slot.
if row.accountShared {
stack := cloneStack(row.stack)
stack.Count = row.offer.Count
destination := accountRecords[accountIDs[recipient.CharacterID]]
if existing, present := destination.Slots[row.sourceKey]; present {
if existing.ItemID != stack.ItemID {
return fmt.Errorf("account warehouse %s: %w", row.sourceKey, ErrItemUnavailable)
}
if existing.Count > math.MaxInt64-stack.Count {
return fmt.Errorf("account warehouse %s: %w", row.sourceKey, ErrItemUnavailable)
}
stack = cloneStack(existing)
stack.Count = existing.Count + row.offer.Count
}
destination.Slots[row.sourceKey] = stack
// Transfer.Stack describes what arrived, not the cell's new
// total: the recipient's client prints its count as
// "购买了[...] N个". The merged total stays reachable through
// Result.Inventories.
delivered := cloneStack(row.stack)
delivered.Count = row.offer.Count
received[recipient.CharacterID] = append(received[recipient.CharacterID], Transfer{
FromCharacterID: participant.CharacterID,
ToCharacterID: recipient.CharacterID,
TradeSlot: row.offer.TradeSlot,
DestinationList: row.offer.SourceList,
DestinationSlot: row.offer.SourceSlot,
Stack: delivered,
})
continue
}
destinationSlot, err := allocateDestinationSlot(record.Slots, row.offer.SourceList, row.offer.SourceSlot)
if err != nil {
return fmt.Errorf("character %s receiving trade slot %d: %w", recipient.CharacterID, row.offer.TradeSlot, err)
}
stack := cloneStack(row.stack)
stack.Count = row.offer.Count
destinationKey := slotKey(row.offer.SourceList, destinationSlot)
record.Slots[destinationKey] = stack
received[recipient.CharacterID] = append(received[recipient.CharacterID], Transfer{
FromCharacterID: participant.CharacterID,
ToCharacterID: recipient.CharacterID,
TradeSlot: row.offer.TradeSlot,
DestinationList: row.offer.SourceList,
DestinationSlot: destinationSlot,
Stack: cloneStack(stack),
})
}
loaded[recipient.CharacterID] = record
}
for _, participant := range participants {
record := loaded[participant.CharacterID]
record.UpdatedAt = o.clock().UTC()
if err := inventories.Save(ctx, record); err != nil {
return err
}
loaded[participant.CharacterID] = record
}
if accountInventories != nil {
for accountID, record := range accountRecords {
record.UpdatedAt = o.clock().UTC()
accountRecords[accountID] = record
if err := accountInventories.Save(ctx, record); err != nil {
return err
}
}
// Callers project op14 rows straight out of Result.Inventories,
// so the returned view has to carry the committed warehouse cells
// as the client addresses them: ordinary list-0 slots.
for _, participant := range participants {
loaded[participant.CharacterID] = dnfrepo.MergeAccountSharedInventory(
loaded[participant.CharacterID], accountRecords[accountIDs[participant.CharacterID]])
}
}
if completionKey != "" {
record := loadedCharacters[receiptCharacterID]
writeItemTradeReceipt(
&record,
participants,
received,
receiptKeyHash,
receiptAuthorityHash,
)
loadedCharacters[receiptCharacterID] = record
}
for _, participant := range participants {
record, changed := loadedCharacters[participant.CharacterID]
if !changed {
continue
}
record.UpdatedAt = o.clock().UTC()
if err := characters.Save(ctx, record); err != nil {
return err
}
loadedCharacters[participant.CharacterID] = record
}
result = Result{Inventories: loaded, Characters: loadedCharacters, Received: received}
return nil
}
var err error
if accountShared {
err = o.repositories.AccountPairTrade.WithinAccountPairCharacterTrade(
ctx,
accountIDs[participants[0].CharacterID],
accountIDs[participants[1].CharacterID],
participants[0].CharacterID,
participants[1].CharacterID,
func(
accountInventories dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
) error {
if accountInventories == nil {
return ErrRepositoryUnavailable
}
return commit(characters, inventories, accountInventories)
},
)
} else {
err = o.repositories.CharacterTrade.WithinCharacterTrade(
ctx,
participants[0].CharacterID,
participants[1].CharacterID,
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
return commit(characters, inventories, nil)
},
)
}
if err != nil {
return Result{}, err
}
return result, nil
}
// ---------------------------------------------------------------------------
// 金币交易手续费与小额交易次数。
//
// 规则(运营定义):
// - 金额 > 100万(含 100万零1):不看次数,收钱方在到账前被扣 3%。
// - 金额 ≤ 100万:小额金币交易。给钱方与收钱方**各自**按自己角色的当日累计
// 次数独立判定 —— 谁在本次交易之前已经 ≥101 次,谁就为这笔金币付 3%;
// 未超限的另一方不受对方状态牵连。
// - 先判后加,所以每个角色每天(北京时间 06:00 换日)前 101 次小额交易免费。
// - 手续费系统回收,不进入任何人的钱包。
//
// 次数与服务日一起存在角色 Stats 里(trade_small_count / trade_small_day),
// 由 exchange 结尾的 characters.Save 与钱包变更同事务落库。
// ---------------------------------------------------------------------------
const (
currentItemTradeFeePercent = int64(3)
currentItemTradeLargeGoldCeiling = int64(1_000_000)
currentItemTradeSmallTradeDailyLimit = int64(101)
currentItemTradeSmallTradeCountStat = "trade_small_count"
currentItemTradeSmallTradeDayStat = "trade_small_day"
)
var currentItemTradeCalendar = time.FixedZone("Asia/Shanghai", 8*60*60)
// currentItemTradeServiceDay 把时刻折算成"北京时间 06:00 换日"的服务日序号
// (与 quest / dungeonadmission 的既有约定一致)。
func currentItemTradeServiceDay(now time.Time) int64 {
local := now.In(currentItemTradeCalendar).Add(-6 * time.Hour)
return int64(local.Year())*10000 + int64(local.Month())*100 + int64(local.Day())
}
// currentItemTradeFee 沿用拍卖行的取整约定:amount * percent / 100(向下取整)。
func currentItemTradeFee(amount int64) int64 {
if amount <= 0 || currentItemTradeFeePercent <= 0 {
return 0
}
if amount > math.MaxInt64/currentItemTradeFeePercent {
return amount / 100 * currentItemTradeFeePercent
}
return amount * currentItemTradeFeePercent / 100
}
func currentItemTradeSmallTradeCount(record dnfrepo.CharacterRecord, day int64) int64 {
if record.Stats == nil || record.Stats[currentItemTradeSmallTradeDayStat] != day {
return 0
}
count := record.Stats[currentItemTradeSmallTradeCountStat]
if count < 0 {
return 0
}
return count
}
func currentItemTradeWithSmallTradeCount(record dnfrepo.CharacterRecord, day int64, count int64) dnfrepo.CharacterRecord {
if record.Stats == nil {
record.Stats = make(map[string]int64, 2)
}
record.Stats[currentItemTradeSmallTradeDayStat] = day
record.Stats[currentItemTradeSmallTradeCountStat] = count
return record
}
// currentItemTradeGoldDeltas 返回每个参与者钱包的净变化量(含手续费),并把新的
// 小额交易次数写回 characters。判定统一使用"本次交易之前"的计数快照,因此结果
// 与参与者的遍历顺序无关;同一角色在同一笔交易里既收又给时,计数按金币流各自累加。
//
// applyGoldTradeFee=false 时(个人商店收购等非玩家交易)退化为纯转移:不收手续费、
// 不写次数,与加入手续费之前的语义逐字一致。
func currentItemTradeGoldDeltas(
participants []Participant,
characters map[string]dnfrepo.CharacterRecord,
now time.Time,
applyGoldTradeFee bool,
) map[string]int64 {
day := currentItemTradeServiceDay(now)
deltas := make(map[string]int64, len(participants))
before := make(map[string]int64, len(participants))
smallFlows := make(map[string]int64, len(participants))
for _, participant := range participants {
deltas[participant.CharacterID] = -participant.Gold
before[participant.CharacterID] = currentItemTradeSmallTradeCount(characters[participant.CharacterID], day)
}
for index, payer := range participants {
recipient := participants[1-index]
amount := payer.Gold
if amount <= 0 {
continue
}
deltas[recipient.CharacterID] += amount
if !applyGoldTradeFee {
continue
}
if amount > currentItemTradeLargeGoldCeiling {
// 大额:收钱方在到账前被扣 3%。
deltas[recipient.CharacterID] -= currentItemTradeFee(amount)
continue
}
// 小额:双方各自独立判定,谁超限谁付。
for _, side := range []string{payer.CharacterID, recipient.CharacterID} {
if before[side] >= currentItemTradeSmallTradeDailyLimit {
deltas[side] -= currentItemTradeFee(amount)
}
smallFlows[side]++
}
}
for _, participant := range participants {
if smallFlows[participant.CharacterID] == 0 {
continue
}
characters[participant.CharacterID] = currentItemTradeWithSmallTradeCount(
characters[participant.CharacterID],
day,
before[participant.CharacterID]+smallFlows[participant.CharacterID],
)
}
return deltas
}
func itemTradeAuthorityHash(participants []Participant) [32]byte {
var canonical bytes.Buffer
for _, participant := range participants {
_ = binary.Write(&canonical, binary.LittleEndian, uint32(len(participant.CharacterID)))
canonical.WriteString(participant.CharacterID)
_ = binary.Write(&canonical, binary.LittleEndian, uint64(participant.Gold))
offers := append([]Offer(nil), participant.Offers...)
sort.Slice(offers, func(i, j int) bool {
if offers[i].TradeSlot != offers[j].TradeSlot {
return offers[i].TradeSlot < offers[j].TradeSlot
}
if offers[i].SourceList != offers[j].SourceList {
return offers[i].SourceList < offers[j].SourceList
}
return offers[i].SourceSlot < offers[j].SourceSlot
})
_ = binary.Write(&canonical, binary.LittleEndian, uint32(len(offers)))
for _, offer := range offers {
_ = binary.Write(&canonical, binary.LittleEndian, offer.TradeSlot)
canonical.WriteByte(offer.SourceList)
_ = binary.Write(&canonical, binary.LittleEndian, offer.SourceSlot)
_ = binary.Write(&canonical, binary.LittleEndian, uint64(offer.Count))
_ = binary.Write(&canonical, binary.LittleEndian, uint64(offer.ExpectedItem))
_ = binary.Write(&canonical, binary.LittleEndian, offer.ExpectedInstance)
canonical.WriteByte(boolByte(offer.ExpectedBind))
canonical.WriteByte(boolByte(offer.AllowSealed))
}
}
return sha256.Sum256(canonical.Bytes())
}
func itemTradeReceiptHashMatches(stats map[string]int64, suffix string, want [32]byte) bool {
for index := 0; index < 4; index++ {
key := fmt.Sprintf("%s%s_%d", itemTradeReceiptPrefix, suffix, index)
if uint64(stats[key]) != binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
return false
}
}
return true
}
func writeItemTradeReceiptHash(stats map[string]int64, suffix string, value [32]byte) {
for index := 0; index < 4; index++ {
stats[fmt.Sprintf("%s%s_%d", itemTradeReceiptPrefix, suffix, index)] =
int64(binary.LittleEndian.Uint64(value[index*8 : (index+1)*8]))
}
}
func writeItemTradeReceipt(
record *dnfrepo.CharacterRecord,
participants []Participant,
received map[string][]Transfer,
keyHash [32]byte,
authorityHash [32]byte,
) {
if record.Stats == nil {
record.Stats = make(map[string]int64)
}
stats := record.Stats
stats[itemTradeReceiptPrefix+"version"] = itemTradeReceiptVersion
writeItemTradeReceiptHash(stats, "key", keyHash)
writeItemTradeReceiptHash(stats, "authority", authorityHash)
transfers := make([]Transfer, 0)
for _, participant := range participants {
transfers = append(transfers, received[participant.CharacterID]...)
}
sort.Slice(transfers, func(i, j int) bool {
if transfers[i].ToCharacterID != transfers[j].ToCharacterID {
return transfers[i].ToCharacterID < transfers[j].ToCharacterID
}
return transfers[i].TradeSlot < transfers[j].TradeSlot
})
stats[itemTradeReceiptPrefix+"transfer_count"] = int64(len(transfers))
for index, transfer := range transfers {
prefix := fmt.Sprintf("%stransfer_%d_", itemTradeReceiptPrefix, index)
fromIndex, toIndex := -1, -1
for participantIndex, participant := range participants {
if participant.CharacterID == transfer.FromCharacterID {
fromIndex = participantIndex
}
if participant.CharacterID == transfer.ToCharacterID {
toIndex = participantIndex
}
}
stats[prefix+"from"] = int64(fromIndex)
stats[prefix+"to"] = int64(toIndex)
stats[prefix+"trade_slot"] = int64(transfer.TradeSlot)
stats[prefix+"destination_list"] = int64(transfer.DestinationList)
stats[prefix+"destination_slot"] = int64(transfer.DestinationSlot)
stats[prefix+"item_id"] = transfer.Stack.ItemID
stats[prefix+"count"] = transfer.Stack.Count
}
}
func replayItemTradeReceipt(
receiptOwner dnfrepo.CharacterRecord,
participants []Participant,
characters map[string]dnfrepo.CharacterRecord,
inventories map[string]dnfrepo.InventoryRecord,
keyHash [32]byte,
authorityHash [32]byte,
) (bool, Result, error) {
stats := receiptOwner.Stats
if stats == nil || !itemTradeReceiptHashMatches(stats, "key", keyHash) {
return false, Result{}, nil
}
if stats[itemTradeReceiptPrefix+"version"] != itemTradeReceiptVersion ||
!itemTradeReceiptHashMatches(stats, "authority", authorityHash) {
return false, Result{}, ErrReceiptInvalid
}
count := stats[itemTradeReceiptPrefix+"transfer_count"]
if count < 0 || count > 64 {
return false, Result{}, ErrReceiptInvalid
}
received := make(map[string][]Transfer, 2)
for index := 0; index < int(count); index++ {
prefix := fmt.Sprintf("%stransfer_%d_", itemTradeReceiptPrefix, index)
fromIndex := stats[prefix+"from"]
toIndex := stats[prefix+"to"]
if fromIndex < 0 || fromIndex >= int64(len(participants)) || toIndex < 0 || toIndex >= int64(len(participants)) || fromIndex == toIndex {
return false, Result{}, ErrReceiptInvalid
}
destinationList := byte(stats[prefix+"destination_list"])
destinationSlot := int16(stats[prefix+"destination_slot"])
itemID := stats[prefix+"item_id"]
itemCount := stats[prefix+"count"]
toCharacterID := participants[toIndex].CharacterID
stack, ok := inventories[toCharacterID].Slots[slotKey(destinationList, destinationSlot)]
if !ok || itemID <= 0 || itemCount <= 0 || stack.ItemID != itemID || stack.Count < itemCount {
return false, Result{}, ErrReceiptInvalid
}
stack = cloneStack(stack)
stack.Count = itemCount
received[toCharacterID] = append(received[toCharacterID], Transfer{
FromCharacterID: participants[fromIndex].CharacterID,
ToCharacterID: toCharacterID,
TradeSlot: uint16(stats[prefix+"trade_slot"]),
DestinationList: destinationList,
DestinationSlot: destinationSlot,
Stack: stack,
})
}
return true, Result{Inventories: inventories, Characters: characters, Received: received, Replayed: true}, nil
}
type stagedOffer struct {
offer Offer
sourceKey string
stack dnfrepo.ItemStack
accountShared bool
}
func validateOffers(record dnfrepo.InventoryRecord, offers []Offer, now time.Time) ([]stagedOffer, error) {
rows := make([]stagedOffer, 0, len(offers))
tradeSlots := make(map[uint16]struct{}, len(offers))
sourceCounts := make(map[string]int64, len(offers))
ordered := append([]Offer(nil), offers...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].TradeSlot < ordered[j].TradeSlot })
for _, offer := range ordered {
if _, duplicate := tradeSlots[offer.TradeSlot]; duplicate || offer.Count <= 0 || !supportedSourceList(offer.SourceList) || offer.SourceSlot < 0 {
return nil, ErrInvalidOffer
}
// The crystal/soul warehouse is a legitimate source: the current client
// offers those cells as its own container (list 36) in the private-store
// and trade windows. They stay account-owned on both sides, so the row is
// marked here and committed against the account record instead of the
// character inventory.
accountShared := dnfrepo.IsAccountSharedInventorySlot(offer.SourceList, offer.SourceSlot)
tradeSlots[offer.TradeSlot] = struct{}{}
key := slotKey(offer.SourceList, offer.SourceSlot)
stack, found := record.Slots[key]
if !found || stack.ItemID <= 0 || stack.Count <= 0 || (offer.ExpectedItem > 0 && stack.ItemID != offer.ExpectedItem) {
return nil, ErrItemUnavailable
}
if offer.ExpectedInstance != 0 && itemTradeEquipmentInstance(stack) != offer.ExpectedInstance {
return nil, ErrItemUnavailable
}
if stack.Bind != offer.ExpectedBind {
return nil, ErrItemUnavailable
}
if (stack.Bind && !offer.AllowSealed) || (!stack.ExpireAt.IsZero() && !stack.ExpireAt.After(now)) || isEquipmentLocked(stack) {
return nil, ErrItemNotTradeable
}
sourceCounts[key] += offer.Count
if sourceCounts[key] > stack.Count {
return nil, ErrItemUnavailable
}
rows = append(rows, stagedOffer{offer: offer, sourceKey: key, stack: cloneStack(stack), accountShared: accountShared})
}
return rows, nil
}
// isEquipmentLocked mirrors the lock-state contract written by the itemlock
// owner (Alt lock, equipment_lock_state): both the active lock and a pending
// unlock keep the item untradeable, matching the client's own panel rule.
func isEquipmentLocked(stack dnfrepo.ItemStack) bool {
if stack.Extra == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(stack.Extra["equipment_lock_state"])) {
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
return true
default:
return false
}
}
// itemTradeEquipmentInstance mirrors the durable identity used by the current
// inventory projection. New equipment stores its quality seed in Extra even
// when a legacy/current 119-byte raw row is unavailable; older imported rows
// may only have raw+0x06. Extra is authoritative when both are present because
// it is also what the client sees and returns in its trade/store request.
//
// itemTradeEquipmentInstance must return the same dword the list projection put
// at +0x06 of the row the client was shown, because that is the value the
// client replays as ExpectedInstance. A full 0x77 equipment row carries the
// quality seed there; a legacy 43-byte row (or none at all) carries something
// else, and the projection then falls back to the stack count. Reading
// RawEntry[6:10] unconditionally answered 0 for those rows, so buying a weapon
// out of a private store failed with "item trade item is unavailable" and the
// client showed 不存在的物品.
func itemTradeEquipmentInstance(stack dnfrepo.ItemStack) uint32 {
if value, err := strconv.ParseUint(strings.TrimSpace(stack.Extra["quality_seed"]), 10, 32); err == nil {
seed := uint32(value)
if itemquality.Valid(seed) {
return seed
}
}
if len(stack.RawEntry) == currentItemRowSize && stack.ItemID > 0 &&
binary.LittleEndian.Uint32(stack.RawEntry[2:6]) == uint32(stack.ItemID) &&
strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "equipment") {
if seed := binary.LittleEndian.Uint32(stack.RawEntry[6:10]); itemquality.Valid(seed) {
return seed
}
}
if stack.Count > 0 && stack.Count <= math.MaxUint32 {
return uint32(stack.Count)
}
return 0
}
func boolByte(value bool) byte {
if value {
return 1
}
return 0
}
func allocateDestinationSlot(slots map[string]dnfrepo.ItemStack, listType byte, preferred int16) (int16, error) {
if !supportedSourceList(listType) {
return 0, ErrInvalidOffer
}
start, end, ok := destinationSlotRange(listType, preferred)
if !ok {
return 0, ErrInvalidOffer
}
for slot := start; slot <= end; slot++ {
if _, occupied := slots[slotKey(listType, slot)]; !occupied {
return slot, nil
}
}
return 0, ErrInventoryFull
}
// destinationSlotRange keeps an incoming item in the same current-client bag
// page as its source. The source index is not a portable destination: copying
// a sender's expanded high slot into a recipient with only the base capacity
// persists the item but leaves it outside the recipient's visible cells.
func destinationSlotRange(listType byte, sourceSlot int16) (int16, int16, bool) {
if listType == avatarListType {
if sourceSlot < avatarFirst || sourceSlot > avatarLast {
return 0, 0, false
}
return avatarFirst, avatarLast, true
}
for _, page := range [...]struct{ first, last int16 }{
{equipmentFirst, equipmentLast},
{consumableFirst, consumableLast},
{materialFirst, materialLast},
{questFirst, questLast},
{professionFirst, professionLast},
{emblemFirst, emblemLast},
{specialFirst, specialLast},
} {
if sourceSlot >= page.first && sourceSlot <= page.last {
return page.first, page.last, true
}
}
return 0, 0, false
}
func supportedSourceList(listType byte) bool {
return listType == mainListType || listType == avatarListType
}
func validDestinationSlot(listType byte, slot int16) bool {
_, _, ok := destinationSlotRange(listType, slot)
return ok
}
func slotKey(listType byte, slot int16) string {
return strconv.Itoa(int(listType)) + ":" + strconv.FormatInt(int64(slot), 10)
}
func cloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if stack.Extra != nil {
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
}
return stack
}
func (o *Owner) clock() time.Time {
if o != nil && o.now != nil {
return o.now()
}
return time.Now()
}
@@ -0,0 +1,282 @@
package itemtrade
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 2026-09-11 12:00 北京时间 => 服务日 20260911(06:00 换日)。
var currentItemTradeFeeTestNow = time.Date(2026, 9, 11, 12, 0, 0, 0,
time.FixedZone("Asia/Shanghai", 8*60*60))
func currentItemTradeFeeTestOwner(t *testing.T, first, second dnfrepo.CharacterRecord) (*Owner, dnfrepo.Group) {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{first, second} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return currentItemTradeFeeTestNow }
return owner, repositories
}
func currentItemTradeFeeCharacter(t *testing.T, repositories dnfrepo.Group, id string) dnfrepo.CharacterRecord {
t.Helper()
record, found, err := repositories.Character.Load(context.Background(), id)
if err != nil || !found {
t.Fatalf("load character %s found=%t err=%v", id, found, err)
}
return record
}
func currentItemTradeFeeSmallTradeRecord(id string, gold int64, count int64) dnfrepo.CharacterRecord {
stats := map[string]int64{"gold": gold}
if count > 0 {
stats[currentItemTradeSmallTradeDayStat] = currentItemTradeServiceDay(currentItemTradeFeeTestNow)
stats[currentItemTradeSmallTradeCountStat] = count
}
return dnfrepo.CharacterRecord{CharacterID: id, AccountID: "acc", Name: "c" + id, Level: 90, Stats: stats}
}
// 小额(≤100万)且双方当日次数都没到 101:不收手续费,双方次数各 +1。
func TestOwnerExchangeSmallTradeUnderDailyLimitIsFree(t *testing.T) {
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 500_000, 100),
currentItemTradeFeeSmallTradeRecord("5", 50_000, 7),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
// 给钱方 500000-100000;收钱方 50000+100000,都不扣手续费。
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 400_000 {
t.Fatalf("sender gold = %d, want 400000", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 150_000 {
t.Fatalf("receiver gold = %d, want 150000", got)
}
sender := currentItemTradeFeeCharacter(t, repositories, "1")
receiver := currentItemTradeFeeCharacter(t, repositories, "5")
if sender.Stats[currentItemTradeSmallTradeCountStat] != 101 {
t.Fatalf("sender small count = %d, want 101", sender.Stats[currentItemTradeSmallTradeCountStat])
}
if receiver.Stats[currentItemTradeSmallTradeCountStat] != 8 {
t.Fatalf("receiver small count = %d, want 8", receiver.Stats[currentItemTradeSmallTradeCountStat])
}
}
// 收钱方当日次数已到 101:收钱方自己付 3%,给钱方完全不受影响。
func TestOwnerExchangeSmallTradeChargesReceiverOverDailyLimit(t *testing.T) {
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 500_000, 0),
currentItemTradeFeeSmallTradeRecord("5", 50_000, 101),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 400_000 {
t.Fatalf("sender gold = %d, want 400000 (unaffected)", got)
}
// 50000 + 100000 - 3% * 100000 = 147000
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 147_000 {
t.Fatalf("receiver gold = %d, want 147000", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats[currentItemTradeSmallTradeCountStat]; got != 102 {
t.Fatalf("receiver small count = %d, want 102", got)
}
}
// 给钱方当日次数已到 101:由给钱方额外承担 3%,收钱方拿到全额。
func TestOwnerExchangeSmallTradeChargesSenderOverDailyLimit(t *testing.T) {
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 500_000, 101),
currentItemTradeFeeSmallTradeRecord("5", 50_000, 0),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
// 500000 - 100000 - 3000 = 397000
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 397_000 {
t.Fatalf("sender gold = %d, want 397000", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 150_000 {
t.Fatalf("receiver gold = %d, want 150000 (unaffected)", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats[currentItemTradeSmallTradeCountStat]; got != 102 {
t.Fatalf("sender small count = %d, want 102", got)
}
}
// 超过 100万:不看次数,收钱方在到账前被扣 3%;小额计数不增加。
func TestOwnerExchangeLargeGoldAlwaysChargesReceiver(t *testing.T) {
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 5_000_000, 0),
currentItemTradeFeeSmallTradeRecord("5", 0, 0),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 1_000_001},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
// 1000001 * 3 / 100 = 30000(向下取整)
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 970_001 {
t.Fatalf("receiver gold = %d, want 970001", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 3_999_999 {
t.Fatalf("sender gold = %d, want 3999999", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats[currentItemTradeSmallTradeCountStat]; got != 0 {
t.Fatalf("large trade must not touch the small-trade counter, got %d", got)
}
}
// 恰好 100万算小额;100万零1 算大额。
func TestOwnerExchangeSmallTradeCeilingIsOneMillion(t *testing.T) {
if currentItemTradeFee(1_000_000) != 30_000 {
t.Fatalf("fee(1000000) = %d", currentItemTradeFee(1_000_000))
}
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 5_000_000, 0),
currentItemTradeFeeSmallTradeRecord("5", 0, 0),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 1_000_000},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 1_000_000 {
t.Fatalf("receiver gold = %d, want 1000000 (exactly one million is a free small trade)", got)
}
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats[currentItemTradeSmallTradeCountStat]; got != 1 {
t.Fatalf("receiver small count = %d, want 1", got)
}
}
// 前一天的服务日计数在 06:00 换日后归零。
func TestOwnerExchangeSmallTradeCounterResetsAtBeijingSix(t *testing.T) {
ctx := context.Background()
sender := currentItemTradeFeeSmallTradeRecord("1", 500_000, 0)
sender.Stats[currentItemTradeSmallTradeDayStat] = 20260910 // 昨天
sender.Stats[currentItemTradeSmallTradeCountStat] = 999
receiver := currentItemTradeFeeSmallTradeRecord("5", 50_000, 0)
owner, repositories := currentItemTradeFeeTestOwner(t, sender, receiver)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5"},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 400_000 {
t.Fatalf("sender gold = %d, want 400000 (yesterday's count must not charge)", got)
}
stored := currentItemTradeFeeCharacter(t, repositories, "1")
if stored.Stats[currentItemTradeSmallTradeDayStat] != 20260911 ||
stored.Stats[currentItemTradeSmallTradeCountStat] != 1 {
t.Fatalf("sender counter day=%d count=%d, want 20260911/1",
stored.Stats[currentItemTradeSmallTradeDayStat],
stored.Stats[currentItemTradeSmallTradeCountStat])
}
}
// 双方都超限:各自为自己的方向付 3%,互不牵连。
func TestOwnerExchangeSmallTradeBothSidesOverLimitPaySeparately(t *testing.T) {
ctx := context.Background()
owner, repositories := currentItemTradeFeeTestOwner(t,
currentItemTradeFeeSmallTradeRecord("1", 500_000, 101),
currentItemTradeFeeSmallTradeRecord("5", 500_000, 101),
)
if _, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5", Gold: 200_000},
); err != nil {
t.Fatalf("Exchange() error = %v", err)
}
// 角色1:-100000(给出)+200000(收到)-6000(收到那份的3%)-3000(给出那份的3%)
if got := currentItemTradeFeeCharacter(t, repositories, "1").Stats["gold"]; got != 591_000 {
t.Fatalf("character 1 gold = %d, want 591000", got)
}
// 角色5:-200000 +100000 -3000 -6000
if got := currentItemTradeFeeCharacter(t, repositories, "5").Stats["gold"]; got != 391_000 {
t.Fatalf("character 5 gold = %d, want 391000", got)
}
}
// 回归保护:个人商店(摆摊)收购走的是 ExchangeWithCompletionKey,不是玩家交易,
// 即使金额远超 100万 也绝对不能收手续费、不能写小额次数。
// 金额特意取大,否则 3% 向下取整会变成 0,测不出问题。
func TestOwnerExchangeWithCompletionKeyNeverChargesTradeFee(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "seller", Name: "seller", Stats: map[string]int64{"gold": 0}},
{CharacterID: "5", AccountID: "buyer", Name: "buyer", Stats: map[string]int64{"gold": 5_000_000}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:65": {ItemID: 2002, Count: 1}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return currentItemTradeFeeTestNow }
seller := Participant{CharacterID: "1", Offers: []Offer{
{TradeSlot: 7, SourceList: 0, SourceSlot: 65, Count: 1, ExpectedItem: 2002},
}}
buyer := Participant{CharacterID: "5", Gold: 2_000_000}
if _, err := owner.ExchangeWithCompletionKey(ctx, "private-store:9", "5", seller, buyer); err != nil {
t.Fatalf("ExchangeWithCompletionKey() error = %v", err)
}
stall := currentItemTradeFeeCharacter(t, repositories, "1")
purchaser := currentItemTradeFeeCharacter(t, repositories, "5")
if stall.Stats["gold"] != 2_000_000 {
t.Fatalf("stall owner gold = %d, want 2000000 (no 3%% trade fee on a private store)", stall.Stats["gold"])
}
if purchaser.Stats["gold"] != 3_000_000 {
t.Fatalf("buyer gold = %d, want 3000000", purchaser.Stats["gold"])
}
for _, id := range []string{"1", "5"} {
record := currentItemTradeFeeCharacter(t, repositories, id)
if _, found := record.Stats[currentItemTradeSmallTradeCountStat]; found {
t.Fatalf("character %s small-trade counter was written by a private store purchase", id)
}
}
}
@@ -0,0 +1,646 @@
package itemtrade
import (
"context"
"encoding/binary"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerExchangeAtomicallyMovesBothOffers(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.InventoryRecord{
{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{"0:97": {ItemID: 1001, Count: 1, RawEntry: []byte{1, 2, 3}}}},
{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{"0:57": {ItemID: 2002, Count: 3}}},
} {
if err := repositories.Inventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return time.Unix(100, 0) }
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 0, SourceList: 0, SourceSlot: 97, Count: 1, ExpectedItem: 1001}}},
Participant{CharacterID: "5", Offers: []Offer{{TradeSlot: 0, SourceList: 0, SourceSlot: 57, Count: 2, ExpectedItem: 2002}}},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
first := result.Inventories["1"]
second := result.Inventories["5"]
if first.Slots["0:9"].ItemID != 2002 || first.Slots["0:9"].Count != 2 {
t.Fatalf("first received = %+v", first.Slots)
}
if second.Slots["0:65"].ItemID != 1001 || second.Slots["0:65"].Count != 1 || second.Slots["0:57"].Count != 1 {
t.Fatalf("second inventory = %+v", second.Slots)
}
if len(result.Received["1"]) != 1 || result.Received["1"][0].TradeSlot != 0 || result.Received["1"][0].DestinationSlot != 9 {
t.Fatalf("first transfer = %+v", result.Received["1"])
}
}
func TestOwnerExchangeRejectsBoundItemWithoutMutation(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:97": {ItemID: 1001, Count: 1, Bind: true}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
_, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 0, SourceList: 0, SourceSlot: 97, Count: 1, ExpectedItem: 1001}}},
Participant{CharacterID: "5"},
)
if !errors.Is(err, ErrItemUnavailable) {
t.Fatalf("Exchange() error = %v", err)
}
loaded, _, _ := repositories.Inventory.Load(ctx, "1")
if loaded.Slots["0:97"].ItemID != 1001 {
t.Fatalf("bound source mutated: %+v", loaded.Slots)
}
}
func TestOwnerExchangeRejectsLockedItemWithoutMutation(t *testing.T) {
ctx := context.Background()
for _, state := range []string{"1", "2", "locked", "unlocking"} {
t.Run(state, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:97": {ItemID: 1001, Count: 1, Extra: map[string]string{
"equipment_lock_state": state,
}}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
_, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 0, SourceList: 0, SourceSlot: 97, Count: 1, ExpectedItem: 1001}}},
Participant{CharacterID: "5"},
)
if !errors.Is(err, ErrItemNotTradeable) {
t.Fatalf("Exchange() error = %v, want ErrItemNotTradeable", err)
}
loaded, _, _ := repositories.Inventory.Load(ctx, "1")
stack := loaded.Slots["0:97"]
if stack.ItemID != 1001 || stack.Extra["equipment_lock_state"] != state {
t.Fatalf("locked source mutated: %+v", loaded.Slots)
}
})
}
}
func TestOwnerExchangeAcceptsOnlyExplicitlyAuthorizedSealedItem(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
stack := dnfrepo.ItemStack{ItemID: 1001, Count: 1, Bind: true}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{"0:97": stack},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
participant := Participant{CharacterID: "1", Offers: []Offer{{
TradeSlot: 0, SourceList: 0, SourceSlot: 97, Count: 1, ExpectedItem: 1001, ExpectedBind: true,
}}}
if _, err := owner.Exchange(ctx, participant, Participant{CharacterID: "5"}); !errors.Is(err, ErrItemNotTradeable) {
t.Fatalf("sealed item without authorization error=%v", err)
}
participant.Offers[0].AllowSealed = true
result, err := owner.Exchange(ctx, participant, Participant{CharacterID: "5"})
if err != nil {
t.Fatalf("authorized sealed exchange: %v", err)
}
if got := result.Inventories["5"].Slots["0:65"]; got.ItemID != 1001 || !got.Bind {
t.Fatalf("received sealed item=%+v", got)
}
}
func TestOwnerExchangeValidatesEquipmentInstance(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, 0x77)
// A faithful 0x77 equipment row: raw+0x02 is the item id and item_kind says
// equipment. Those are what make raw+0x06 the quality seed rather than the
// projected stack count.
binary.LittleEndian.PutUint32(raw[2:6], 100230483)
binary.LittleEndian.PutUint32(raw[6:10], 0x0A48BC23)
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:9": {
ItemID: 100230483, Count: 1, RawEntry: raw,
Extra: map[string]string{"item_kind": "equipment"},
}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
bad := Participant{CharacterID: "1", Offers: []Offer{{
TradeSlot: 2, SourceList: 0, SourceSlot: 9, Count: 1, ExpectedItem: 100230483, ExpectedInstance: 0x01020304,
}}}
if _, err := owner.Exchange(ctx, bad, Participant{CharacterID: "5"}); !errors.Is(err, ErrItemUnavailable) {
t.Fatalf("mismatched equipment instance error = %v", err)
}
good := bad
good.Offers = append([]Offer(nil), bad.Offers...)
good.Offers[0].ExpectedInstance = 0x0A48BC23
result, err := owner.Exchange(ctx, good, Participant{CharacterID: "5"})
if err != nil {
t.Fatalf("matching equipment instance error = %v", err)
}
if got := result.Inventories["5"].Slots["0:9"]; got.ItemID != 100230483 || got.Count != 1 {
t.Fatalf("received equipment = %+v", got)
}
}
func TestOwnerExchangeValidatesEquipmentInstanceFromDurableQualitySeed(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:9": {
ItemID: 100230483,
Count: 1,
Extra: map[string]string{"item_kind": "equipment", "quality_seed": "669297134"},
}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
bad := Participant{CharacterID: "1", Offers: []Offer{{
TradeSlot: 2, SourceList: 0, SourceSlot: 9, Count: 1, ExpectedItem: 100230483, ExpectedInstance: 0x01020304,
}}}
if _, err := owner.Exchange(ctx, bad, Participant{CharacterID: "5"}); !errors.Is(err, ErrItemUnavailable) {
t.Fatalf("mismatched metadata instance error = %v", err)
}
good := bad
good.Offers = append([]Offer(nil), bad.Offers...)
good.Offers[0].ExpectedInstance = 0x27E4A9EE
result, err := owner.Exchange(ctx, good, Participant{CharacterID: "5"})
if err != nil {
t.Fatalf("matching metadata instance error = %v", err)
}
if got := result.Inventories["5"].Slots["0:9"]; got.ItemID != 100230483 || got.Count != 1 || got.Extra["quality_seed"] != "669297134" {
t.Fatalf("received metadata-only equipment = %+v", got)
}
}
func TestOwnerExchangeAtomicallyMovesGoldAndItem(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "a", Stats: map[string]int64{"gold": 250_000}},
{CharacterID: "5", AccountID: "b", Stats: map[string]int64{"gold": 40_000}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "5",
Slots: map[string]dnfrepo.ItemStack{"0:57": {ItemID: 2002, Count: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100_000},
Participant{CharacterID: "5", Gold: 10_000, Offers: []Offer{{TradeSlot: 1, SourceList: 0, SourceSlot: 57, Count: 1, ExpectedItem: 2002}}},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if got := result.Characters["1"].Stats["gold"]; got != 160_000 {
t.Fatalf("first gold = %d", got)
}
if got := result.Characters["5"].Stats["gold"]; got != 130_000 {
t.Fatalf("second gold = %d", got)
}
if got := result.Inventories["1"].Slots["0:9"]; got.ItemID != 2002 || got.Count != 1 {
t.Fatalf("first received item = %+v", got)
}
}
func TestOwnerExchangePlacesExpandedSourceSlotAtVisiblePageStart(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:110": {ItemID: 490702514, Count: 1}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 1, SourceList: 0, SourceSlot: 110, Count: 1, ExpectedItem: 490702514}}},
Participant{CharacterID: "7"},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if got := result.Inventories["7"].Slots["0:65"]; got.ItemID != 490702514 || got.Count != 1 {
t.Fatalf("recipient visible consumable slot = %+v, inventory = %+v", got, result.Inventories["7"].Slots)
}
if got := result.Received["7"][0].DestinationSlot; got != 65 {
t.Fatalf("destination slot = %d, want 65", got)
}
}
// TestOwnerExchangePlacesEmblemFromTheNinthRowAtEmblemPageStart 覆盖徽章页第 9 行(345..351)。
//
// 服主现场口径:徽章页是 9 行 × 7 格 = 63(289..351)。这里的分页常量原先写 289..344,
// 把 345..351 划给了 special 页;那会让落在最后一行上的徽章**交易不出去**
// (destinationSlotRange 找不到所属页 → 整笔拒绝)。同时钉住 special 页只剩 352..353。
func TestOwnerExchangePlacesEmblemFromTheNinthRowAtEmblemPageStart(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
// 345 = 徽章页第 9 行第一格。
Slots: map[string]dnfrepo.ItemStack{"0:345": {ItemID: 2520037, Count: 1}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 1, SourceList: 0, SourceSlot: 345, Count: 1, ExpectedItem: 2520037}}},
Participant{CharacterID: "7"},
)
if err != nil {
t.Fatalf("Exchange() error = %v(徽章页第 9 行必须能交易)", err)
}
// 落点回到徽章页页首(与"扩展格物品收进可见页首"同一口径)。
if got := result.Inventories["7"].Slots["0:289"]; got.ItemID != 2520037 || got.Count != 1 {
t.Fatalf("recipient emblem page start = %+v, inventory = %+v", got, result.Inventories["7"].Slots)
}
if got := result.Received["7"][0].DestinationSlot; got != 289 {
t.Fatalf("destination slot = %d, want 289", got)
}
for _, slot := range []int16{345, 351} {
if first, last, ok := destinationSlotRange(mainListType, slot); !ok || first != emblemFirst || last != emblemLast {
t.Fatalf("slot %d page = %d..%d ok=%t, want emblem %d..%d", slot, first, last, ok, emblemFirst, emblemLast)
}
}
if first, last, ok := destinationSlotRange(mainListType, 352); !ok || first != specialFirst || last != specialLast {
t.Fatalf("slot 352 page = %d..%d ok=%t, want special %d..%d", first, last, ok, specialFirst, specialLast)
}
if emblemFirst != 289 || emblemLast != 351 {
t.Fatalf("徽章页常量漂了:%d..%d", emblemFirst, emblemLast)
}
}
func TestOwnerExchangeRejectsInsufficientGoldWithoutMovingItem(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "a", Stats: map[string]int64{"gold": 99}},
{CharacterID: "5", AccountID: "b", Stats: map[string]int64{"gold": 0}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "5",
Slots: map[string]dnfrepo.ItemStack{"0:57": {ItemID: 2002, Count: 1}},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
_, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Gold: 100},
Participant{CharacterID: "5", Offers: []Offer{{TradeSlot: 1, SourceList: 0, SourceSlot: 57, Count: 1, ExpectedItem: 2002}}},
)
if !errors.Is(err, ErrGoldUnavailable) {
t.Fatalf("Exchange() error = %v", err)
}
character, _, _ := repositories.Character.Load(ctx, "1")
if got := character.Stats["gold"]; got != 99 {
t.Fatalf("first gold mutated to %d", got)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "5")
if got := inventory.Slots["0:57"]; got.ItemID != 2002 || got.Count != 1 {
t.Fatalf("offered item mutated: %+v", inventory.Slots)
}
}
func TestOwnerExchangeWithCompletionKeyReplaysIndeterminateCommit(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "seller", Stats: map[string]int64{"gold": 10}},
{CharacterID: "5", AccountID: "buyer", Stats: map[string]int64{"gold": 100}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{"0:65": {ItemID: 2002, Count: 2}},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
indeterminate := &commitThenErrorCharacterTrade{delegate: repositories.CharacterTrade, remainingErrors: 1}
repositories.CharacterTrade = indeterminate
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
seller := Participant{CharacterID: "1", Offers: []Offer{{TradeSlot: 7, SourceList: 0, SourceSlot: 65, Count: 1, ExpectedItem: 2002}}}
buyer := Participant{CharacterID: "5", Gold: 25}
if _, err := owner.ExchangeWithCompletionKey(ctx, "private-store:1", "5", seller, buyer); err == nil {
t.Fatal("first indeterminate commit returned nil error")
}
result, err := owner.ExchangeWithCompletionKey(ctx, "private-store:1", "5", seller, buyer)
if err != nil {
t.Fatalf("replay error = %v", err)
}
if !result.Replayed || len(result.Received["5"]) != 1 || result.Received["5"][0].Stack.ItemID != 2002 {
t.Fatalf("replay result = %+v", result)
}
sellerCharacter, _, _ := repositories.Character.Load(ctx, "1")
buyerCharacter, _, _ := repositories.Character.Load(ctx, "5")
sellerInventory, _, _ := repositories.Inventory.Load(ctx, "1")
buyerInventory, _, _ := repositories.Inventory.Load(ctx, "5")
if sellerCharacter.Stats["gold"] != 35 || buyerCharacter.Stats["gold"] != 75 ||
sellerInventory.Slots["0:65"].Count != 1 || buyerInventory.Slots["0:65"].Count != 1 {
t.Fatalf("seller=%+v buyer=%+v seller_inventory=%+v buyer_inventory=%+v", sellerCharacter, buyerCharacter, sellerInventory, buyerInventory)
}
if _, err := owner.ExchangeWithCompletionKey(ctx, "private-store:1", "5", seller, Participant{CharacterID: "5", Gold: 26}); !errors.Is(err, ErrReceiptInvalid) {
t.Fatalf("changed authority error = %v", err)
}
}
type commitThenErrorCharacterTrade struct {
delegate dnfrepo.CharacterTradeUnitOfWork
remainingErrors int
}
func (u *commitThenErrorCharacterTrade) WithinCharacterTrade(
ctx context.Context,
firstCharacterID string,
secondCharacterID string,
apply func(dnfrepo.CharacterRepository, dnfrepo.InventoryRepository) error,
) error {
if err := u.delegate.WithinCharacterTrade(ctx, firstCharacterID, secondCharacterID, apply); err != nil {
return err
}
if u.remainingErrors > 0 {
u.remainingErrors--
return errors.New("simulated commit outcome unknown")
}
return nil
}
var _ dnfrepo.CharacterTradeUnitOfWork = (*commitThenErrorCharacterTrade)(nil)
// The account-owned crystal/soul warehouse is a legal trade source. Its cells
// stay account-owned on both sides, so the debit and the credit both commit
// against the account record inside the wider account/character transaction.
func TestOwnerExchangeMovesAccountWarehouseStackWithinOneAccount(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
const accountID = "dnf:acct"
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: accountID,
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 1000}},
}); err != nil {
t.Fatal(err)
}
for _, record := range []dnfrepo.InventoryRecord{
{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{}},
{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}},
} {
if err := repositories.Inventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: accountID, Stats: map[string]int64{"gold": 0}},
{CharacterID: "5", AccountID: accountID, Stats: map[string]int64{"gold": 5000}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return time.Unix(100, 0) }
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", AccountID: accountID, Offers: []Offer{
{TradeSlot: 3, SourceList: 0, SourceSlot: 358, Count: 200, ExpectedItem: 3037},
}},
Participant{CharacterID: "5", AccountID: accountID, Gold: 1500},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
transfers := result.Received["5"]
if len(transfers) != 1 || transfers[0].DestinationList != 0 || transfers[0].DestinationSlot != 358 {
t.Fatalf("buyer transfers = %+v", transfers)
}
// One account owns one warehouse, so the cell keeps its total: the trade
// moves gold between the two characters and leaves the shared stack intact.
// Selling to your own alt is therefore a gold-only transfer.
stored, found, err := repositories.AccountInventory.Load(ctx, accountID)
if err != nil || !found {
t.Fatalf("account inventory load found=%t err=%v", found, err)
}
if stored.Slots["0:358"].ItemID != 3037 || stored.Slots["0:358"].Count != 1000 {
t.Fatalf("account warehouse = %+v", stored.Slots)
}
if result.Inventories["5"].Slots["0:358"].Count != 1000 {
t.Fatalf("buyer merged view = %+v", result.Inventories["5"].Slots)
}
if result.Characters["1"].Stats["gold"] != 1500 || result.Characters["5"].Stats["gold"] != 3500 {
t.Fatalf("gold seller=%d buyer=%d", result.Characters["1"].Stats["gold"], result.Characters["5"].Stats["gold"])
}
}
// A cross-account sale is the real case: the crystal leaves the seller's
// warehouse and lands in the buyer's own warehouse cell, both inside one
// transaction.
func TestOwnerExchangeMovesAccountWarehouseStackAcrossAccounts(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, record := range []dnfrepo.AccountInventoryRecord{
{AccountID: "dnf:a", Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 1000}}},
{AccountID: "dnf:b", Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 7}}},
} {
if err := repositories.AccountInventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
for _, record := range []dnfrepo.InventoryRecord{
{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{}},
{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}},
} {
if err := repositories.Inventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
for _, record := range []dnfrepo.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:a", Stats: map[string]int64{"gold": 0}},
{CharacterID: "5", AccountID: "dnf:b", Stats: map[string]int64{"gold": 5000}},
} {
if err := repositories.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return time.Unix(100, 0) }
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", AccountID: "dnf:a", Offers: []Offer{
{TradeSlot: 3, SourceList: 0, SourceSlot: 358, Count: 200, ExpectedItem: 3037},
}},
Participant{CharacterID: "5", AccountID: "dnf:b", Gold: 1500},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
seller, found, err := repositories.AccountInventory.Load(ctx, "dnf:a")
if err != nil || !found || seller.Slots["0:358"].Count != 800 {
t.Fatalf("seller warehouse found=%t err=%v slots=%+v", found, err, seller.Slots)
}
buyer, found, err := repositories.AccountInventory.Load(ctx, "dnf:b")
if err != nil || !found || buyer.Slots["0:358"].Count != 207 || buyer.Slots["0:358"].ItemID != 3037 {
t.Fatalf("buyer warehouse found=%t err=%v slots=%+v", found, err, buyer.Slots)
}
if result.Inventories["5"].Slots["0:358"].Count != 207 || result.Inventories["1"].Slots["0:358"].Count != 800 {
t.Fatalf("merged views seller=%+v buyer=%+v", result.Inventories["1"].Slots, result.Inventories["5"].Slots)
}
if result.Characters["1"].Stats["gold"] != 1500 || result.Characters["5"].Stats["gold"] != 3500 {
t.Fatalf("gold seller=%d buyer=%d", result.Characters["1"].Stats["gold"], result.Characters["5"].Stats["gold"])
}
}
// An account-owned offer without a resolved account owner must refuse: the
// transaction cannot decide which warehouse to debit.
func TestOwnerExchangeRefusesAccountWarehouseWithoutAccountOwner(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "dnf:a",
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 1000}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
_, err = owner.Exchange(ctx,
Participant{CharacterID: "1", AccountID: "dnf:a", Offers: []Offer{
{TradeSlot: 0, SourceList: 0, SourceSlot: 358, Count: 1, ExpectedItem: 3037},
}},
Participant{CharacterID: "5"},
)
if !errors.Is(err, ErrItemNotTradeable) {
t.Fatalf("Exchange() error = %v, want ErrItemNotTradeable", err)
}
}
// A private-store row freezes ExpectedInstance from the list projection, which
// falls back to the stack count when the row is not a full 0x77 equipment
// entry. Reading RawEntry[6:10] for such a row answered 0 and every weapon
// purchase failed with ErrItemUnavailable.
func TestOwnerExchangeAcceptsLegacyEquipmentRowInstance(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
legacy := dnfrepo.ItemStack{
ItemID: 101010912,
Count: 1,
Extra: map[string]string{"item_kind": "equipment"},
// The live 43-byte row: raw+0x06 is not the quality seed here.
RawEntry: []byte{0x0b, 0xe0, 0x4d, 0x05, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00},
}
if got := itemTradeEquipmentInstance(legacy); got != 1 {
t.Fatalf("legacy equipment instance = %d, want the projected count 1", got)
}
for _, record := range []dnfrepo.InventoryRecord{
{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{"0:16": legacy}},
{CharacterID: "5", Slots: map[string]dnfrepo.ItemStack{}},
} {
if err := repositories.Inventory.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
owner.now = func() time.Time { return time.Unix(100, 0) }
result, err := owner.Exchange(ctx,
Participant{CharacterID: "1", Offers: []Offer{
{TradeSlot: 4, SourceList: 0, SourceSlot: 16, Count: 1, ExpectedItem: 101010912, ExpectedInstance: 1},
}},
Participant{CharacterID: "5"},
)
if err != nil {
t.Fatalf("Exchange() error = %v", err)
}
if len(result.Received["5"]) != 1 || result.Received["5"][0].Stack.ItemID != 101010912 {
t.Fatalf("buyer transfers = %+v", result.Received["5"])
}
}