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