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