服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
868 lines
37 KiB
Go
868 lines
37 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
"longheng.io/server/internal/modules/dnf/inventory"
|
||
"longheng.io/server/internal/modules/dnf/itemtrade"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
currentItemTradeListType byte = 4
|
||
currentItemTradeChangeMsg uint16 = 15
|
||
currentItemTradeCancelMsg uint16 = 16
|
||
currentItemTradeStateMsg uint16 = 17
|
||
currentItemTradeFinishMsg uint16 = 18
|
||
currentItemTradeMoveMsg uint16 = 19
|
||
currentItemTradeReadyMsg uint16 = uint16(dnfenum.CmdPacketSetItemtradeState)
|
||
currentItemTradeAddingDone byte = 1
|
||
currentItemTradeOfferAck byte = 2
|
||
currentItemTradeReady byte = 3
|
||
currentItemTradeAddRequest byte = 5
|
||
// currentItemTradeWindowClosed 是「本侧关闭了交易窗口」的唯一可用信号。
|
||
//
|
||
// 当前客户端关窗时不发任何原生包:它唯一的解锁发送器 sub_23418C0(flag)
|
||
// (发 op24 + state 0/1) 在本客户端里只被「添加完成」按钮以 flag=1 调用,
|
||
// state 0 那条路径不可达。因此 state 0 在协议上是空闲取值,由 90CN.dll 在
|
||
// 交易窗口拆卸路径补发,用于表达"我的交易窗口关了"。
|
||
currentItemTradeWindowClosed byte = 0
|
||
currentItemTradeOfferFirst uint16 = 3
|
||
currentItemTradeOfferLast uint16 = 11
|
||
)
|
||
|
||
type onlineItemTradeOffer struct {
|
||
tradeSlot uint16
|
||
sourceList byte
|
||
sourceSlot int16
|
||
count int32
|
||
itemID int64
|
||
stack dnfrepo.ItemStack
|
||
}
|
||
|
||
type onlineItemTrade struct {
|
||
participants [2]uint16
|
||
offers map[uint16]map[uint16]onlineItemTradeOffer
|
||
gold map[uint16]int64
|
||
states map[uint16]byte
|
||
committing bool
|
||
// createdAt 用于区分「客户端开窗复位」与「用户真的关窗」两种 state 0。
|
||
// 客户端在打开交易窗口时会复位窗口,该复位路径会补发 state 0;此时交易
|
||
// 刚建立且完全为空,因此这类 state 0 必须在时间窗内被忽略。
|
||
createdAt time.Time
|
||
}
|
||
|
||
// currentItemTradeOpenResetGrace 是「开窗复位误报」的时间窗。
|
||
//
|
||
// 90CN.dll 只在真关窗时才补发 state 0,但它判定关窗的依据是"窗口显示标志为 0
|
||
// 且交易已绑定对方";交易刚建立、窗口尚未显示的那一瞬间也满足这个组合,故仍留
|
||
// 一个很短的窗口作为防御。 实测误报只出现在建立后 ≤100ms,而真实关窗最快也要
|
||
// 人看到窗口之后(实测 671~1644ms),因此 500ms 既能挡住误报又不会吞掉真关窗。
|
||
// 早期版本用 2 秒,会吞掉"开窗后立刻关窗"这类真实取消。
|
||
const currentItemTradeOpenResetGrace = 500 * time.Millisecond
|
||
|
||
// onlineItemTradeHasContent 判定这笔交易是否已经承载任何玩家意图。
|
||
//
|
||
// 空交易 = 双方都没有报价、没有金币、state 都为 0。 只有「刚创建 + 完全为空」
|
||
// 的 state 0 才是开窗复位;一旦有任何内容,state 0 都按真实取消处理。
|
||
func onlineItemTradeHasContent(trade *onlineItemTrade) bool {
|
||
if trade == nil {
|
||
return false
|
||
}
|
||
for _, characterID := range trade.participants {
|
||
if trade.states[characterID] != 0 || trade.gold[characterID] != 0 ||
|
||
len(trade.offers[characterID]) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (t *onlineItemTrade) other(characterID uint16) (uint16, bool) {
|
||
if t == nil {
|
||
return 0, false
|
||
}
|
||
if t.participants[0] == characterID {
|
||
return t.participants[1], true
|
||
}
|
||
if t.participants[1] == characterID {
|
||
return t.participants[0], true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func (s *Service) beginOnlineItemTrade(first, second *gameSession) {
|
||
if s == nil || first == nil || second == nil || first == second || first.selectedCharacterID == 0 || second.selectedCharacterID == 0 {
|
||
return
|
||
}
|
||
firstID, secondID := first.selectedCharacterID, second.selectedCharacterID
|
||
if s.onlinePlayers == nil || !s.onlinePlayers.PeerInSameArea(firstID, secondID) {
|
||
return
|
||
}
|
||
s.tradeMu.Lock()
|
||
defer s.tradeMu.Unlock()
|
||
if s.itemTrades == nil {
|
||
s.itemTrades = make(map[uint16]*onlineItemTrade)
|
||
}
|
||
for _, id := range []uint16{firstID, secondID} {
|
||
if old := s.itemTrades[id]; old != nil {
|
||
delete(s.itemTrades, old.participants[0])
|
||
delete(s.itemTrades, old.participants[1])
|
||
}
|
||
}
|
||
trade := &onlineItemTrade{
|
||
participants: [2]uint16{firstID, secondID},
|
||
offers: map[uint16]map[uint16]onlineItemTradeOffer{
|
||
firstID: make(map[uint16]onlineItemTradeOffer),
|
||
secondID: make(map[uint16]onlineItemTradeOffer),
|
||
},
|
||
gold: map[uint16]int64{firstID: 0, secondID: 0},
|
||
states: map[uint16]byte{firstID: 0, secondID: 0},
|
||
createdAt: time.Now(),
|
||
}
|
||
s.itemTrades[firstID] = trade
|
||
s.itemTrades[secondID] = trade
|
||
}
|
||
|
||
func (s *Service) handleOnlineItemTradeCommand(session *gameSession, typ uint16, body []byte) (bool, error) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return false, nil
|
||
}
|
||
trade := s.currentOnlineItemTrade(session.selectedCharacterID)
|
||
if trade == nil {
|
||
return false, nil
|
||
}
|
||
switch typ {
|
||
case currentItemTradeMoveMsg:
|
||
request, err := inventory.DecodeMoveItemspaceRequest(body)
|
||
if err != nil {
|
||
return true, s.sendGameUpperFailure(session, typ, 1)
|
||
}
|
||
if request.SourceListType != currentItemTradeListType && request.DestinationListType != currentItemTradeListType {
|
||
return false, nil
|
||
}
|
||
return true, s.handleOnlineItemTradeMove(session, request)
|
||
case currentItemTradeReadyMsg:
|
||
if len(body) != 1 {
|
||
return true, s.sendGameUpperFailure(session, typ, 1)
|
||
}
|
||
return true, s.handleOnlineItemTradeState(session, body[0])
|
||
default:
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
func (s *Service) currentOnlineItemTrade(characterID uint16) *onlineItemTrade {
|
||
s.tradeMu.Lock()
|
||
defer s.tradeMu.Unlock()
|
||
return s.itemTrades[characterID]
|
||
}
|
||
|
||
func (s *Service) handleOnlineItemTradeMove(session *gameSession, request inventory.MoveItemspaceRequest) error {
|
||
if request.DestinationListType == currentItemTradeListType && request.SourceListType != currentItemTradeListType {
|
||
if isOnlineItemTradeGoldDeposit(request) {
|
||
return s.addOnlineItemTradeGold(session, request)
|
||
}
|
||
return s.addOnlineItemTradeOffer(session, request)
|
||
}
|
||
if request.SourceListType == currentItemTradeListType && request.DestinationListType != currentItemTradeListType {
|
||
if isOnlineItemTradeGoldWithdrawal(request) {
|
||
return s.removeOnlineItemTradeGold(session, request)
|
||
}
|
||
return s.removeOnlineItemTradeOffer(session, request)
|
||
}
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
|
||
// onlineItemTradeOffersEditable 判定「本侧是否仍可修改报价」(放入/取回物品、存入/取回金币)。
|
||
//
|
||
// 语义与时序依据(配合 handleOnlineItemTradeState 的状态投影表):
|
||
// - states[char] == 0:本侧尚未提交「添加完成」,报价可自由增删;
|
||
// - states[char] == currentItemTradeAddingDone(1):客户端已上报 5 并被投影为「添加完成」,
|
||
// 该侧界面进入锁定态(此后只剩「最终确认 / 取消」两个动作)。此时若仍允许改报价,
|
||
// 会出现「本侧已锁定却继续增删报价 → 服务端权威报价与本侧界面、对方界面三者不一致」,
|
||
// 故锁定后必须禁止修改;
|
||
// - states[char] == 2(报价确认)/ 3(最终确认)以及 trade.committing:均处于锁定之后,
|
||
// 同样不可改;
|
||
// - 客户端可重新上报 state 0 解锁(见 handleOnlineItemTradeState 的 case 0),解锁后方可再改。
|
||
//
|
||
// 物品报价与金币报价共用本判定,保证两侧锁定语义完全一致。
|
||
func onlineItemTradeOffersEditable(trade *onlineItemTrade, characterID uint16) bool {
|
||
if trade == nil || trade.committing {
|
||
return false
|
||
}
|
||
return trade.states[characterID] == 0
|
||
}
|
||
|
||
func isOnlineItemTradeGoldDeposit(request inventory.MoveItemspaceRequest) bool {
|
||
return request.SourceListType == dnfrepo.MainInventoryListType &&
|
||
request.SourceSlotIndex == 0 && request.SourceInstanceValue == 0 &&
|
||
request.MoveCount > 0 && request.DestinationListType == currentItemTradeListType &&
|
||
request.DestinationSlotIndex == 0
|
||
}
|
||
|
||
func isOnlineItemTradeGoldWithdrawal(request inventory.MoveItemspaceRequest) bool {
|
||
return request.SourceListType == currentItemTradeListType && request.SourceSlotIndex == 0 &&
|
||
request.DestinationListType == dnfrepo.MainInventoryListType && request.DestinationSlotIndex == 0
|
||
}
|
||
|
||
func (s *Service) addOnlineItemTradeGold(session *gameSession, request inventory.MoveItemspaceRequest) error {
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Character == nil {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
||
defer cancel()
|
||
character, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
|
||
if err != nil || !found || int64(request.MoveCount) > character.Stats["gold"] {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[session.selectedCharacterID]
|
||
peerID, inTrade := trade.other(session.selectedCharacterID)
|
||
// 金币报价同受「添加完成即锁定」约束:只有本侧仍处于可编辑态(state 0)才允许存入。
|
||
valid := inTrade && onlineItemTradeOffersEditable(trade, session.selectedCharacterID)
|
||
previousGold := int64(0)
|
||
if valid {
|
||
previousGold = trade.gold[session.selectedCharacterID]
|
||
trade.gold[session.selectedCharacterID] = int64(request.MoveCount)
|
||
}
|
||
s.tradeMu.Unlock()
|
||
peer, peerOnline := s.onlineGameSession(peerID)
|
||
if !valid || !peerOnline || s.onlinePlayers == nil || !s.onlinePlayers.PeerInSameArea(session.selectedCharacterID, peerID) {
|
||
if valid {
|
||
s.tradeMu.Lock()
|
||
if current := s.itemTrades[session.selectedCharacterID]; current == trade && !current.committing {
|
||
current.gold[session.selectedCharacterID] = previousGold
|
||
}
|
||
s.tradeMu.Unlock()
|
||
}
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeMoveMsg, buildOnlineItemTradeMoveAck(request)); err != nil {
|
||
return err
|
||
}
|
||
entry := currentGoldWalletItemListEntry(int64(request.MoveCount))
|
||
if err := s.sendGameUpperRawClassCodec(peer, currentItemTradeChangeMsg, entry.data[:], 0, true); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-item-trade-gold-added",
|
||
"char_id", session.selectedCharacterID, "peer_char_id", peerID, "gold", request.MoveCount)
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) removeOnlineItemTradeGold(session *gameSession, request inventory.MoveItemspaceRequest) error {
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[session.selectedCharacterID]
|
||
peerID, inTrade := trade.other(session.selectedCharacterID)
|
||
gold := int64(0)
|
||
// 本侧已锁定(state 1/2/3)或交易进入提交阶段时不得再取回金币,保持金币报价与锁定态一致。
|
||
if inTrade && onlineItemTradeOffersEditable(trade, session.selectedCharacterID) {
|
||
gold = trade.gold[session.selectedCharacterID]
|
||
if gold > 0 {
|
||
trade.gold[session.selectedCharacterID] = 0
|
||
}
|
||
}
|
||
s.tradeMu.Unlock()
|
||
if gold <= 0 {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeMoveMsg, buildOnlineItemTradeMoveAck(request)); err != nil {
|
||
return err
|
||
}
|
||
peer, peerOnline := s.onlineGameSession(peerID)
|
||
if !peerOnline {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClassCodec(peer, currentItemTradeChangeMsg, buildOnlineItemTradeRemovedEntry(0), 0, true)
|
||
}
|
||
|
||
func (s *Service) addOnlineItemTradeOffer(session *gameSession, request inventory.MoveItemspaceRequest) error {
|
||
// The account-owned crystal/soul warehouse reaches the trade window under
|
||
// the client's own container id; the slot number is still the merged list-0
|
||
// cell, so normalize it back before touching the repository.
|
||
if request.SourceListType == currentPrivateStoreAccountSharedListType &&
|
||
dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, request.SourceSlotIndex) {
|
||
request.SourceListType = dnfrepo.MainInventoryListType
|
||
}
|
||
if request.MoveCount <= 0 || request.DestinationSlotIndex < 0 || request.SourceSlotIndex < 0 ||
|
||
(request.SourceListType != dnfrepo.MainInventoryListType && request.SourceListType != 1) {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Inventory == nil {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
||
defer cancel()
|
||
record, found, err := s.currentPrivateStoreInventoryView(
|
||
ctx, repositories, strconv.Itoa(int(session.selectedCharacterID)), s.accountIDForSession(session))
|
||
if err != nil || !found {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
sourceKey := fmt.Sprintf("%d:%d", request.SourceListType, request.SourceSlotIndex)
|
||
stack, found := record.Slots[sourceKey]
|
||
if !found || stack.ItemID <= 0 || stack.Count < int64(request.MoveCount) || stack.Bind ||
|
||
(!stack.ExpireAt.IsZero() && !stack.ExpireAt.After(time.Now())) {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
// The request's source-instance field is an opaque value read from the
|
||
// current client's live item object. It is not the PVF item ID and is not
|
||
// stable across every equipment/stackable representation. The repository
|
||
// slot and authoritative count above are sufficient to prevent item
|
||
// injection, so a projection mismatch must not reject a valid trade.
|
||
projectedSourceValue := currentItemListStackAmount(request.SourceListType, stack)
|
||
if request.SourceInstanceValue != 0 && uint32(request.SourceInstanceValue) != projectedSourceValue {
|
||
s.logGameEvent(session, "game-item-trade-source-instance-projection-mismatch",
|
||
"char_id", session.selectedCharacterID,
|
||
"source_list", request.SourceListType,
|
||
"source_slot", request.SourceSlotIndex,
|
||
"client_value", request.SourceInstanceValue,
|
||
"projected_value", projectedSourceValue)
|
||
}
|
||
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[session.selectedCharacterID]
|
||
peerID, inTrade := trade.other(session.selectedCharacterID)
|
||
// 本侧提交「添加完成」后界面进入锁定态,锁定后不得再新增/替换报价(见 onlineItemTradeOffersEditable)。
|
||
valid := inTrade && onlineItemTradeOffersEditable(trade, session.selectedCharacterID)
|
||
tradeSlot := uint16(0)
|
||
slotAvailable := false
|
||
if valid {
|
||
// Current NoPack's trade container has twelve native slots. Live current-
|
||
// EXE memory and sub_2339FB0 show 0..2 are occupied pseudo rows (gold and
|
||
// the other trade currencies); the nine visible item rows are 3..11.
|
||
// Targeting 0 renders a count-one item as +1 gold, while 1/2 collide with
|
||
// the remaining pseudo rows and leave the item list blank.
|
||
for slot := currentItemTradeOfferFirst; slot <= currentItemTradeOfferLast; slot++ {
|
||
if _, occupied := trade.offers[session.selectedCharacterID][slot]; !occupied {
|
||
tradeSlot = slot
|
||
slotAvailable = true
|
||
break
|
||
}
|
||
}
|
||
for _, existing := range trade.offers[session.selectedCharacterID] {
|
||
if existing.sourceList == request.SourceListType && existing.sourceSlot == request.SourceSlotIndex {
|
||
valid = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
valid = valid && slotAvailable
|
||
offer := onlineItemTradeOffer{
|
||
tradeSlot: tradeSlot, sourceList: request.SourceListType, sourceSlot: request.SourceSlotIndex,
|
||
count: request.MoveCount, itemID: stack.ItemID, stack: cloneOnlineItemTradeStack(stack),
|
||
}
|
||
if valid {
|
||
trade.offers[session.selectedCharacterID][tradeSlot] = offer
|
||
}
|
||
s.tradeMu.Unlock()
|
||
peer, peerOnline := s.onlineGameSession(peerID)
|
||
if !valid || !peerOnline || s.onlinePlayers == nil || !s.onlinePlayers.PeerInSameArea(session.selectedCharacterID, peerID) {
|
||
if valid {
|
||
s.removeOnlineItemTradeOfferState(session.selectedCharacterID, tradeSlot)
|
||
}
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
|
||
// The request always targets pseudo-container slot 0 because the client does
|
||
// not allocate the visible row itself. Echo the server-assigned native item
|
||
// row in the success ACK; 0..2 are pseudo rows and item rows are 3..11.
|
||
ackRequest := request
|
||
ackRequest.DestinationSlotIndex = int16(tradeSlot)
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeMoveMsg, buildOnlineItemTradeMoveAck(ackRequest)); err != nil {
|
||
return err
|
||
}
|
||
offer.stack.Count = int64(offer.count)
|
||
entry := currentItemListEntryFromStack(offer.sourceList, int16(offer.tradeSlot), offer.stack)
|
||
if err := s.sendGameUpperRawClassCodec(peer, currentItemTradeChangeMsg, entry.data[:], 0, true); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-item-trade-offer-added",
|
||
"char_id", session.selectedCharacterID, "peer_char_id", peerID,
|
||
"source_list", offer.sourceList, "source_slot", offer.sourceSlot,
|
||
"trade_slot", offer.tradeSlot, "item_id", offer.itemID, "count", offer.count)
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) removeOnlineItemTradeOffer(session *gameSession, request inventory.MoveItemspaceRequest) error {
|
||
tradeSlot := uint16(request.SourceSlotIndex)
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[session.selectedCharacterID]
|
||
peerID, inTrade := trade.other(session.selectedCharacterID)
|
||
var offer onlineItemTradeOffer
|
||
var found bool
|
||
// 锁定后不得取回报价:物品报价在双方「添加完成」时即视为最终报价,此后仅剩最终确认或整单取消。
|
||
if inTrade && onlineItemTradeOffersEditable(trade, session.selectedCharacterID) {
|
||
offer, found = trade.offers[session.selectedCharacterID][tradeSlot]
|
||
if found {
|
||
delete(trade.offers[session.selectedCharacterID], tradeSlot)
|
||
}
|
||
}
|
||
s.tradeMu.Unlock()
|
||
if !found || request.DestinationListType != offer.sourceList {
|
||
return s.sendGameUpperFailure(session, currentItemTradeMoveMsg, 1)
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeMoveMsg, buildOnlineItemTradeMoveAck(request)); err != nil {
|
||
return err
|
||
}
|
||
peer, peerOnline := s.onlineGameSession(peerID)
|
||
if !peerOnline {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClassCodec(peer, currentItemTradeChangeMsg, buildOnlineItemTradeRemovedEntry(tradeSlot), 0, true)
|
||
}
|
||
|
||
func buildOnlineItemTradeRemovedEntry(tradeSlot uint16) []byte {
|
||
removed := make([]byte, currentItemListEntryWireSize)
|
||
binary.LittleEndian.PutUint16(removed[0:2], tradeSlot)
|
||
binary.LittleEndian.PutUint32(removed[2:6], ^uint32(0))
|
||
return removed
|
||
}
|
||
|
||
func (s *Service) removeOnlineItemTradeOfferState(characterID, tradeSlot uint16) {
|
||
s.tradeMu.Lock()
|
||
defer s.tradeMu.Unlock()
|
||
if trade := s.itemTrades[characterID]; trade != nil && !trade.committing {
|
||
delete(trade.offers[characterID], tradeSlot)
|
||
}
|
||
}
|
||
|
||
func (s *Service) handleOnlineItemTradeState(session *gameSession, state byte) error {
|
||
if state != 0 && state != currentItemTradeAddingDone && state != currentItemTradeOfferAck && state != currentItemTradeReady && state != currentItemTradeAddRequest {
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
characterID := session.selectedCharacterID
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[characterID]
|
||
peerID, inTrade := trade.other(characterID)
|
||
if !inTrade || trade.committing {
|
||
s.tradeMu.Unlock()
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
peerState := trade.states[peerID]
|
||
if state == currentItemTradeOfferAck {
|
||
currentState := trade.states[characterID]
|
||
if currentState == currentItemTradeReady {
|
||
s.tradeMu.Unlock()
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
s.tradeMu.Unlock()
|
||
s.logGameEvent(session, "game-item-trade-offer-change-acknowledged",
|
||
"char_id", characterID,
|
||
"peer_char_id", peerID,
|
||
"retained_state", currentState,
|
||
"peer_state", peerState)
|
||
return s.sendGameUpperSuccess(session, currentItemTradeReadyMsg, nil)
|
||
}
|
||
projectedState := state
|
||
// Live current-EXE traffic distinguishes the two buttons even though the
|
||
// server-facing state table does not: "adding complete" submits 5 and must
|
||
// be projected to class0/op17 state 1; choosing Yes in resource 727 submits
|
||
// 3 and must be projected as state 3. A second value 5 is retained only as
|
||
// a compatibility spelling of final consent for older clients. Neither
|
||
// spelling may skip the shared state-1 confirmation boundary.
|
||
switch state {
|
||
case currentItemTradeAddRequest:
|
||
if trade.states[characterID] == 0 {
|
||
projectedState = currentItemTradeAddingDone
|
||
} else if trade.states[characterID] == currentItemTradeAddingDone &&
|
||
(peerState == currentItemTradeAddingDone || peerState == currentItemTradeReady) {
|
||
projectedState = currentItemTradeReady
|
||
} else {
|
||
s.tradeMu.Unlock()
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
case currentItemTradeReady:
|
||
if trade.states[characterID] != currentItemTradeAddingDone ||
|
||
(peerState != currentItemTradeAddingDone && peerState != currentItemTradeReady) {
|
||
s.tradeMu.Unlock()
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
case currentItemTradeAddingDone:
|
||
if trade.states[characterID] != 0 {
|
||
s.tradeMu.Unlock()
|
||
return s.sendGameUpperFailure(session, currentItemTradeReadyMsg, 1)
|
||
}
|
||
case currentItemTradeWindowClosed:
|
||
// 本侧交易窗口复位/关闭。 90CN.dll 在窗口"打开"与"关闭"两条路径上都会
|
||
// 补发这条 state 0(原生复位函数是同一个),因此这里必须先排除开窗:
|
||
// 刚建立且完全为空的交易收到 state 0 = 客户端开窗复位,不是用户取消。
|
||
// 否则交易窗口一开就会被自己取消。
|
||
age := time.Since(trade.createdAt)
|
||
if !onlineItemTradeHasContent(trade) && age < currentItemTradeOpenResetGrace {
|
||
s.tradeMu.Unlock()
|
||
s.logGameEvent(session, "game-item-trade-window-open-reset-ignored",
|
||
"char_id", characterID,
|
||
"peer_char_id", peerID,
|
||
"age_ms", age.Milliseconds())
|
||
return s.sendGameUpperSuccess(session, currentItemTradeReadyMsg, nil)
|
||
}
|
||
// 真实取消:整单取消,而不是像旧行为那样"广播 0 但不取消会话"。
|
||
// 不取消的后果是:对方窗口一直不关、双方停在交易态导致 NPC 商店拒绝
|
||
// 购买、客户端已搬进交易容器的物品不回背包(服务端从未移动过物品,
|
||
// 只有客户端以为物品在交易容器里)。
|
||
s.tradeMu.Unlock()
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeReadyMsg, nil); err != nil {
|
||
return err
|
||
}
|
||
s.cancelOnlineItemTrade(trade, "client_closed_trade_window")
|
||
return nil
|
||
}
|
||
trade.states[characterID] = projectedState
|
||
peerState = trade.states[peerID]
|
||
ready := projectedState == currentItemTradeReady && peerState == currentItemTradeReady
|
||
addingDone := projectedState == currentItemTradeAddingDone && peerState == currentItemTradeAddingDone
|
||
// 双方都提交「添加完成(锁定)」是本侧与对侧报价的最终定稿时刻:此刻双方都不能再改报价,
|
||
// 因此在下面 ACK 之后需要立刻向双方各重放一次「对方」的报价快照,补齐客户端
|
||
// 「双方锁定后交易界面显示对方物品」的缺失环节。重放必须等 tradeMu 释放后再发包。
|
||
replayCounterpartOffers := addingDone
|
||
if ready {
|
||
trade.committing = true
|
||
}
|
||
s.tradeMu.Unlock()
|
||
s.logGameEvent(session, "game-item-trade-state-updated",
|
||
"char_id", characterID,
|
||
"peer_char_id", peerID,
|
||
"inbound_state", state,
|
||
"projected_state", projectedState,
|
||
"peer_state", peerState,
|
||
"both_adding_done", addingDone,
|
||
"both_ready", ready)
|
||
|
||
if err := s.sendGameUpperSuccess(session, currentItemTradeReadyMsg, nil); err != nil {
|
||
return err
|
||
}
|
||
if replayCounterpartOffers {
|
||
// 时序:此处 tradeMu 已在上一步释放,满足「发包必须在 tradeMu 释放之后」的硬约束。
|
||
// 先给本侧重放对方报价(本侧刚锁定,界面需要立刻补齐对方物品),再给对方重放本侧报价。
|
||
// 硬约束:物品内容只走 op15(class 0),绝不挂到 op17;发包前逐一判定对端是否在线。
|
||
if err := s.replayOnlineItemTradeCounterpartOffers(trade, session); err != nil {
|
||
return err
|
||
}
|
||
if peer, peerOnline := s.onlineGameSession(peerID); peerOnline {
|
||
if err := s.replayOnlineItemTradeCounterpartOffers(trade, peer); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
// The current client moves itself to "waiting for the other participant's
|
||
// final confirmation" as soon as it submits state 3. Broadcasting op17/3
|
||
// here would make sub_2340B10 close resource 727 on the peer that has not
|
||
// answered yet, preventing the second consent. Keep final consent server-
|
||
// side only; the second consent is followed directly by the atomic op18
|
||
// completion (or the existing op16 rollback on failure).
|
||
if projectedState == currentItemTradeReady {
|
||
if ready {
|
||
return s.commitOnlineItemTrade(trade)
|
||
}
|
||
return nil
|
||
}
|
||
stateBody := buildOnlineItemTradeStateBody(characterID, projectedState)
|
||
if err := s.sendGameUpperRawClassCodec(session, currentItemTradeStateMsg, stateBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
peer, peerOnline := s.onlineGameSession(peerID)
|
||
if peerOnline {
|
||
if err := s.sendGameUpperRawClassCodec(peer, currentItemTradeStateMsg, stateBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) commitOnlineItemTrade(trade *onlineItemTrade) error {
|
||
if trade == nil {
|
||
return nil
|
||
}
|
||
firstID, secondID := trade.participants[0], trade.participants[1]
|
||
firstOffers, secondOffers := snapshotOnlineItemTradeOffers(trade, firstID), snapshotOnlineItemTradeOffers(trade, secondID)
|
||
firstGold, secondGold := snapshotOnlineItemTradeGold(trade, firstID), snapshotOnlineItemTradeGold(trade, secondID)
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
s.cancelOnlineItemTrade(trade, "repository_unavailable")
|
||
return nil
|
||
}
|
||
owner, err := itemtrade.NewOwner(repositories)
|
||
if err != nil {
|
||
s.cancelOnlineItemTrade(trade, "transaction_owner_unavailable")
|
||
return nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), currentRepositorySnapshotTimeout)
|
||
defer cancel()
|
||
result, err := owner.Exchange(ctx,
|
||
itemtrade.Participant{CharacterID: strconv.Itoa(int(firstID)), AccountID: s.accountIDForCharacter(ctx, repositories, firstID), Offers: itemTradeDomainOffers(firstOffers), Gold: firstGold},
|
||
itemtrade.Participant{CharacterID: strconv.Itoa(int(secondID)), AccountID: s.accountIDForCharacter(ctx, repositories, secondID), Offers: itemTradeDomainOffers(secondOffers), Gold: secondGold},
|
||
)
|
||
if err != nil {
|
||
s.logInfo("dnfbridge item trade atomic commit rejected", "first_char_id", firstID, "second_char_id", secondID, "error", err)
|
||
s.cancelOnlineItemTrade(trade, "atomic_commit_rejected")
|
||
return nil
|
||
}
|
||
s.clearOnlineItemTrade(trade)
|
||
for _, characterID := range []uint16{firstID, secondID} {
|
||
session, online := s.onlineGameSession(characterID)
|
||
if !online {
|
||
continue
|
||
}
|
||
// 这个角色本次收到了多少金币 = 对方报价的金币。
|
||
peerCharID := firstID
|
||
receivedGold := firstGold
|
||
if characterID == firstID {
|
||
peerCharID = secondID
|
||
receivedGold = secondGold
|
||
}
|
||
character, goldChanged := result.Characters[strconv.Itoa(int(characterID))]
|
||
|
||
// 钱包行必须在结果弹窗【之前】先下发一次。
|
||
//
|
||
// 客户端的「获得 XXXX 金币」系统提示只在钱包行的数值变多时触发;结果弹窗一旦
|
||
// 出现就会把这条提示盖掉/抑制掉。实测:补上金币行让弹窗正常弹出之后,这条提示
|
||
// 就再没出现过。先发一次让提示照常触发,弹窗之后再发一次同样的值兜底 ——
|
||
// 无论客户端是否按 op18 的金币行自行结算,最终钱包数值都一定是正确的。
|
||
if goldChanged {
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentGoldStateBody(character.Stats["gold"]), 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
body := buildOnlineItemTradeFinishBody(
|
||
result.Received[strconv.Itoa(int(characterID))], receivedGold)
|
||
s.logPacketEvent("game-item-trade-finish-rows",
|
||
"char_id", characterID,
|
||
"peer_char_id", peerCharID,
|
||
"item_rows", len(result.Received[strconv.Itoa(int(characterID))]),
|
||
"gold_row", boolByte(receivedGold > 0),
|
||
"received_gold", receivedGold,
|
||
"gold_state_before_finish", boolByte(goldChanged),
|
||
"rows", len(body)/4)
|
||
if err := s.sendGameUpperRawClassCodec(session, currentItemTradeFinishMsg, body, 0, true); err != nil {
|
||
return err
|
||
}
|
||
if goldChanged {
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentGoldStateBody(character.Stats["gold"]), 0); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendSelectedCurrentContainerListsRefresh(session, "current_exe_item_trade_atomic_commit"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.logInfo("dnfbridge item trade atomic commit completed", "first_char_id", firstID, "second_char_id", secondID,
|
||
"first_offer_count", len(firstOffers), "second_offer_count", len(secondOffers),
|
||
"first_gold", firstGold, "second_gold", secondGold)
|
||
return nil
|
||
}
|
||
|
||
func snapshotOnlineItemTradeGold(trade *onlineItemTrade, characterID uint16) int64 {
|
||
if trade == nil {
|
||
return 0
|
||
}
|
||
return trade.gold[characterID]
|
||
}
|
||
|
||
func snapshotOnlineItemTradeOffers(trade *onlineItemTrade, characterID uint16) []onlineItemTradeOffer {
|
||
offers := make([]onlineItemTradeOffer, 0, len(trade.offers[characterID]))
|
||
for _, offer := range trade.offers[characterID] {
|
||
offers = append(offers, offer)
|
||
}
|
||
sort.Slice(offers, func(i, j int) bool { return offers[i].tradeSlot < offers[j].tradeSlot })
|
||
return offers
|
||
}
|
||
|
||
// replayOnlineItemTradeCounterpartOffers 向 recipient 重放「对方」已放入的报价行与金币行,
|
||
// 用于补齐「双方都点击添加完成(锁定)后,客户端交易界面显示对方物品」的缺失环节。
|
||
//
|
||
// 协议依据(与放入瞬间的逐件下发完全同构,保证客户端按同一路径解析):
|
||
// - 统一走 op15 currentItemTradeChangeMsg 的 class-0 通道,包体为单条 currentItemListEntry
|
||
// (0x77 字节),不参与 op17(op17 只承载 charID + state,禁止挂物品内容);
|
||
// - 物品行:currentItemListEntryFromStack(offer.sourceList, offer.tradeSlot, stack),槽位使用
|
||
// 服务端分配的 tradeSlot(3..11,与 addOnlineItemTradeOffer 的分配规则一致);
|
||
// - 行内数量必须取「本次放入数量」offer.count,不能直接用源背包槽的完整堆叠数量
|
||
// (与 addOnlineItemTradeOffer 中 offer.stack.Count = int64(offer.count) 同理);
|
||
// - 金币行:currentGoldWalletItemListEntry(gold) 写入伪行 0(与 addOnlineItemTradeGold 一致);
|
||
// 对方当前没有金币报价时,补发 buildOnlineItemTradeRemovedEntry(0) 做幂等清行
|
||
// (与 removeOnlineItemTradeGold 的撤回下发同构),避免重复重放残留旧的对方金币显示。
|
||
//
|
||
// 重复调用是幂等的:同一槽位按行覆盖,客户端重复收到同一行不会重复计数。
|
||
//
|
||
// 时序与锁:调用方必须已释放 tradeMu(sendGameUpperRawClassCodec 内部还要取 wire 锁),
|
||
// 并且必须先确认 recipient 在线;本函数只在取快照时短暂加锁,随后逐行发包。
|
||
func (s *Service) replayOnlineItemTradeCounterpartOffers(trade *onlineItemTrade, recipient *gameSession) error {
|
||
if s == nil || trade == nil || recipient == nil || recipient.selectedCharacterID == 0 {
|
||
return nil
|
||
}
|
||
peerID, ok := trade.other(recipient.selectedCharacterID)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
var offers []onlineItemTradeOffer
|
||
var gold int64
|
||
s.tradeMu.Lock()
|
||
// 交易若已进入提交(对方已完成最终确认,界面即将由 op18 收尾)或已被取消/替换,
|
||
// 则不再补发 op15,避免与提交结果竞争。
|
||
if s.itemTrades[recipient.selectedCharacterID] == trade && !trade.committing {
|
||
offers = snapshotOnlineItemTradeOffers(trade, peerID)
|
||
gold = snapshotOnlineItemTradeGold(trade, peerID)
|
||
}
|
||
s.tradeMu.Unlock()
|
||
for _, offer := range offers {
|
||
stack := offer.stack
|
||
stack.Count = int64(offer.count)
|
||
entry := currentItemListEntryFromStack(offer.sourceList, int16(offer.tradeSlot), stack)
|
||
if err := s.sendGameUpperRawClassCodec(recipient, currentItemTradeChangeMsg, entry.data[:], 0, true); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if gold > 0 {
|
||
entry := currentGoldWalletItemListEntry(gold)
|
||
return s.sendGameUpperRawClassCodec(recipient, currentItemTradeChangeMsg, entry.data[:], 0, true)
|
||
}
|
||
return s.sendGameUpperRawClassCodec(recipient, currentItemTradeChangeMsg, buildOnlineItemTradeRemovedEntry(0), 0, true)
|
||
}
|
||
|
||
func itemTradeDomainOffers(offers []onlineItemTradeOffer) []itemtrade.Offer {
|
||
rows := make([]itemtrade.Offer, 0, len(offers))
|
||
for _, offer := range offers {
|
||
rows = append(rows, itemtrade.Offer{TradeSlot: offer.tradeSlot, SourceList: offer.sourceList,
|
||
SourceSlot: offer.sourceSlot, Count: int64(offer.count), ExpectedItem: offer.itemID})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func (s *Service) clearOnlineItemTrade(trade *onlineItemTrade) {
|
||
s.tradeMu.Lock()
|
||
defer s.tradeMu.Unlock()
|
||
if trade != nil {
|
||
for _, characterID := range trade.participants {
|
||
if s.itemTrades[characterID] == trade {
|
||
delete(s.itemTrades, characterID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) cancelOnlineItemTrade(trade *onlineItemTrade, reason string) {
|
||
if trade == nil {
|
||
return
|
||
}
|
||
firstID, secondID := trade.participants[0], trade.participants[1]
|
||
firstOffers, secondOffers := snapshotOnlineItemTradeOffers(trade, firstID), snapshotOnlineItemTradeOffers(trade, secondID)
|
||
firstGold, secondGold := snapshotOnlineItemTradeGold(trade, firstID), snapshotOnlineItemTradeGold(trade, secondID)
|
||
s.clearOnlineItemTrade(trade)
|
||
for _, row := range []struct {
|
||
characterID uint16
|
||
offers []onlineItemTradeOffer
|
||
gold int64
|
||
}{{firstID, firstOffers, firstGold}, {secondID, secondOffers, secondGold}} {
|
||
if session, online := s.onlineGameSession(row.characterID); online {
|
||
_ = s.sendGameUpperRawClassCodec(session, currentItemTradeCancelMsg, buildOnlineItemTradeCancelBody(row.offers, row.gold), 0, true)
|
||
}
|
||
}
|
||
s.logInfo("dnfbridge item trade cancelled", "first_char_id", firstID, "second_char_id", secondID, "reason", reason)
|
||
}
|
||
|
||
func (s *Service) detachOnlineItemTrade(session *gameSession, reason string) {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return
|
||
}
|
||
s.tradeMu.Lock()
|
||
trade := s.itemTrades[session.selectedCharacterID]
|
||
s.tradeMu.Unlock()
|
||
if trade != nil {
|
||
s.cancelOnlineItemTrade(trade, reason)
|
||
}
|
||
}
|
||
|
||
func buildOnlineItemTradeMoveAck(request inventory.MoveItemspaceRequest) []byte {
|
||
var writer packetWriter
|
||
writer.writeByte(request.SourceListType)
|
||
writer.writeUint16(uint16(request.SourceSlotIndex))
|
||
writer.writeInt32(int(request.MoveCount))
|
||
writer.writeByte(request.DestinationListType)
|
||
writer.writeUint16(uint16(request.DestinationSlotIndex))
|
||
return writer.bytes()
|
||
}
|
||
|
||
func buildOnlineItemTradeStateBody(characterID uint16, state byte) []byte {
|
||
var writer packetWriter
|
||
writer.writeUint16(characterID)
|
||
writer.writeByte(state)
|
||
return writer.bytes()
|
||
}
|
||
|
||
func buildOnlineItemTradeCancelBody(offers []onlineItemTradeOffer, gold int64) []byte {
|
||
var writer packetWriter
|
||
count := len(offers)
|
||
if gold > 0 {
|
||
count++
|
||
}
|
||
writer.writeUint16(uint16(count))
|
||
if gold > 0 {
|
||
writer.writeUint16(0)
|
||
writer.writeByte(dnfrepo.MainInventoryListType)
|
||
writer.writeUint16(0)
|
||
}
|
||
for _, offer := range offers {
|
||
writer.writeUint16(offer.tradeSlot)
|
||
writer.writeByte(offer.sourceList)
|
||
writer.writeUint16(uint16(offer.sourceSlot))
|
||
}
|
||
return writer.bytes()
|
||
}
|
||
|
||
// buildOnlineItemTradeFinishBody 构造 op18「交易完成」包体。
|
||
//
|
||
// 客户端处理器 sub_1D61130 的解析格式(反汇编确认):
|
||
//
|
||
// u16 行数
|
||
// 行数 × (u16 交易槽, u16 目标槽)
|
||
//
|
||
// 之后不再读任何字节 —— 也就是**没有独立的金币字段**。
|
||
//
|
||
// 实测缺陷:只发物品行时,纯金币交易的行数是 0,客户端据此认为「没有结果」而
|
||
// 完全不弹结果窗(收到金币的一方尤其明显);有物品时行数 >0 才会弹,但金额恒为 0。
|
||
//
|
||
// 因此这里为**收到金币**的一方补一条金币伪行,让行数至少为 1:
|
||
// 交易槽 0(交易界面的 0..2 是保留伪行,0 = 金币)、目标槽 0(钱包伪行),
|
||
// 与 op16 取消包体里的金币行同构(buildOnlineItemTradeCancelBody)。
|
||
//
|
||
// 这是本轮实验:先验证「行数>0 才弹窗」这个条件。金额字段能否一并显示,
|
||
// 取决于客户端弹窗的数据来源(它读的是已被清空的交易状态),可能仍需客户端侧改动。
|
||
func buildOnlineItemTradeFinishBody(transfers []itemtrade.Transfer, receivedGold int64) []byte {
|
||
rows := len(transfers)
|
||
if receivedGold > 0 {
|
||
rows++
|
||
}
|
||
var writer packetWriter
|
||
writer.writeUint16(uint16(rows))
|
||
for _, transfer := range transfers {
|
||
writer.writeUint16(transfer.TradeSlot)
|
||
writer.writeUint16(uint16(transfer.DestinationSlot))
|
||
}
|
||
if receivedGold > 0 {
|
||
writer.writeUint16(0)
|
||
writer.writeUint16(0)
|
||
}
|
||
return writer.bytes()
|
||
}
|
||
|
||
func cloneOnlineItemTradeStack(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
|
||
}
|