服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
661 lines
30 KiB
Go
661 lines
30 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const currentCargoTransportOpcode uint16 = uint16(dnfenum.CmdPacketCargoTransportItem)
|
|
|
|
// The op557 response handler in the current EXE is sub_1E785B0 (registered in
|
|
// the third registry: push handler; push 0x22D; call sub_1CF2A70; call
|
|
// sub_225C880). Its failure branch at 0x1E788CD reads the class-1 error code
|
|
// from [ebp+0x10] and only recognises seven values -- 0x04, 0x07, 0x11, 0x13,
|
|
// 0x15, 0x16 and 0xD6 -- each mapping to one dstr id (0x89E4, 0x89DD, 0x89E3,
|
|
// 0x89E2, 0x33B3, 0x89E5 and, through the shared table sub_32DDC70, 0xA4F8).
|
|
// `esi` is zeroed at 0x1E788D0 before the compare chain, so **any other code
|
|
// falls through with dstr id 0** and the client pops whatever string happens
|
|
// to sit first in dstr.dat -- in this build "已超出机器人放置的个数上限", which
|
|
// has nothing to do with cargo transport.
|
|
//
|
|
// The seven texts, read out of docs/client-dstr-zh.txt:
|
|
//
|
|
// 0x04 -> 35300 帐号金库空间不足
|
|
// 0x07 -> 35293 无法转移该道具
|
|
// 0x11 -> 35299 该道具不存在
|
|
// 0x13 -> 35298 帐号金库未激活,无法使用。
|
|
// 0x15 -> 13235 无法使用。
|
|
// 0x16 -> 35301 所需材料不足
|
|
// 0xD6 -> 42232 在物品已锁定状态下,您无法进行当前操作。
|
|
//
|
|
// ServerS4A21's CargoTransportItemAckBuilder maps its SourceMissing family to
|
|
// 0x16 and everything else to 0x07; with the strings readable that turns out
|
|
// to be wrong for 0x16, which is the material message and the single most
|
|
// common rejection this opcode has. The mapping below follows the text.
|
|
const (
|
|
// currentCargoTransportErrorNotTransferable -- "无法转移该道具" -- is the
|
|
// catch-all: an ineligible target, an unresolvable recipient, a malformed
|
|
// request, and any fault this server has not classified.
|
|
currentCargoTransportErrorNotTransferable byte = 0x07
|
|
// currentCargoTransportErrorItemMissing -- "该道具不存在" -- the slot the
|
|
// request named holds nothing.
|
|
currentCargoTransportErrorItemMissing byte = 0x11
|
|
// currentCargoTransportErrorCargoInactive -- "帐号金库未激活,无法使用。"
|
|
currentCargoTransportErrorCargoInactive byte = 0x13
|
|
// currentCargoTransportErrorCargoFull -- "帐号金库空间不足"
|
|
currentCargoTransportErrorCargoFull byte = 0x04
|
|
// currentCargoTransportErrorMaterial -- "所需材料不足" -- any of the four
|
|
// Grandi costs (soul, element crystal, epic fragment, particular item)
|
|
// the player is short of.
|
|
currentCargoTransportErrorMaterial byte = 0x16
|
|
)
|
|
|
|
// currentCargoTransportGrandiMode is the request's mode byte for 歌兰蒂斯
|
|
// 装备跨界, and the same value the success ACK echoes back as its trailing
|
|
// mode byte. sub_1E785B0 closes the 装备跨界 window (UI id 0x435) at
|
|
// 0x1E78870 only for mode 2.
|
|
const currentCargoTransportGrandiMode byte = 2
|
|
|
|
// currentCargoTransportRejection carries the client-recognised error code
|
|
// alongside the reason that goes to the log.
|
|
type currentCargoTransportRejection struct {
|
|
code byte
|
|
reason string
|
|
}
|
|
|
|
func (e currentCargoTransportRejection) Error() string { return e.reason }
|
|
|
|
func currentCargoTransportReject(code byte, format string, args ...any) error {
|
|
return currentCargoTransportRejection{code: code, reason: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// currentCargoTransportFailureCode maps one commit failure onto a code the
|
|
// op557 handler recognises. An uncoded error -- a repository or PVF fault, or
|
|
// a rejection this server has not classified yet -- becomes the generic code
|
|
// rather than reaching the client as an unknown value.
|
|
func currentCargoTransportFailureCode(err error) byte {
|
|
var rejection currentCargoTransportRejection
|
|
if errors.As(err, &rejection) && rejection.code != 0 {
|
|
return rejection.code
|
|
}
|
|
return currentCargoTransportErrorNotTransferable
|
|
}
|
|
|
|
// buildCurrentCargoTransportSuccessBody is the op557 success payload
|
|
// sub_1E785B0 reads at 0x1E78621: four u16 followed by the mode byte. The
|
|
// first u16 is the stone slot the handler repaints, the third its remaining
|
|
// count (0 removes the row) and the fourth a per-mode parameter; the layout
|
|
// matches ServerS4A21's CargoTransportItemAckBuilder. For mode 2 the handler
|
|
// closes the window and returns before touching the item rows, so the
|
|
// remaining count and parameter are zero.
|
|
func buildCurrentCargoTransportSuccessBody(stoneSlot, targetSlot, remainingStoneCount, parameter uint16, mode byte) []byte {
|
|
body := make([]byte, 9)
|
|
binary.LittleEndian.PutUint16(body[0:2], stoneSlot)
|
|
binary.LittleEndian.PutUint16(body[2:4], targetSlot)
|
|
binary.LittleEndian.PutUint16(body[4:6], remainingStoneCount)
|
|
binary.LittleEndian.PutUint16(body[6:8], parameter)
|
|
body[8] = mode
|
|
return body
|
|
}
|
|
|
|
type currentCargoTransportRequest struct {
|
|
StoneSlot int16
|
|
TargetSlot int16
|
|
Mode byte
|
|
RecipientSelect uint32
|
|
}
|
|
|
|
func decodeCurrentCargoTransportRequest(body []byte) (currentCargoTransportRequest, error) {
|
|
if len(body) != 9 {
|
|
return currentCargoTransportRequest{}, fmt.Errorf("cargo transport body length=%d want=9", len(body))
|
|
}
|
|
return currentCargoTransportRequest{
|
|
StoneSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
|
|
TargetSlot: int16(binary.LittleEndian.Uint16(body[2:4])),
|
|
Mode: body[4],
|
|
RecipientSelect: binary.LittleEndian.Uint32(body[5:9]),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentCargoTransport(session *gameSession, body []byte) error {
|
|
request, err := decodeCurrentCargoTransportRequest(body)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentCargoTransportOpcode, currentCargoTransportErrorNotTransferable)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, currentCargoTransportOpcode, currentCargoTransportErrorNotTransferable)
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentCargoTransportOpcode, currentCargoTransportErrorNotTransferable)
|
|
}
|
|
config, err := loadCurrentCargoTransportConfig(catalog)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, currentCargoTransportOpcode, currentCargoTransportErrorNotTransferable)
|
|
}
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
characterID := selectedCharacterIDString(session)
|
|
switch request.Mode {
|
|
case 0:
|
|
err = s.commitCurrentStoneToAccountCargo(ctx, repositories, catalog, config, accountID, characterID, request)
|
|
case 1:
|
|
err = s.commitCurrentPetTransport(ctx, repositories, catalog, accountID, characterID, request)
|
|
case 2:
|
|
err = s.commitCurrentGrandiEquipmentTransport(ctx, repositories, catalog, config, accountID, characterID, request)
|
|
default:
|
|
err = errors.New("unsupported cargo transport mode")
|
|
}
|
|
if err != nil {
|
|
code := currentCargoTransportFailureCode(err)
|
|
s.logGameEvent(session, "game-cargo-transport-blocked", "mode", request.Mode, "stone_slot", request.StoneSlot, "target_slot", request.TargetSlot, "recipient", request.RecipientSelect, "error_code", code, "err", err)
|
|
return s.sendGameUpperFailure(session, currentCargoTransportOpcode, code)
|
|
}
|
|
for _, listType := range currentCargoTransportRefreshLists(request.Mode) {
|
|
listBody, _, _, built := s.buildCurrentItemListBodyForSession(ctx, session, listType)
|
|
if !built {
|
|
return fmt.Errorf("build cargo transport list=%d", listType)
|
|
}
|
|
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), listBody); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if request.Mode == currentCargoTransportGrandiMode {
|
|
// Only 装备跨界 gets the success ACK today: it is the one mode whose
|
|
// client branch is settled (close UI 0x435, touch no item row). Modes
|
|
// 0 and 1 repaint the stone stack from the same body, and sending a
|
|
// wrong slot/count pair there would corrupt the bag, so they keep the
|
|
// refresh-only behaviour until their fields are read off the EXE too.
|
|
return s.sendGameUpperSuccess(session, currentCargoTransportOpcode, buildCurrentCargoTransportSuccessBody(
|
|
uint16(request.StoneSlot), uint16(request.TargetSlot), 0, 0, currentCargoTransportGrandiMode))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentCargoTransportRefreshLists(mode byte) []byte {
|
|
switch mode {
|
|
case 0:
|
|
return []byte{dnfrepo.MainInventoryListType, 12}
|
|
case 1:
|
|
return []byte{dnfrepo.MainInventoryListType, 7}
|
|
default:
|
|
return []byte{dnfrepo.MainInventoryListType}
|
|
}
|
|
}
|
|
|
|
func (s *Service) commitCurrentStoneToAccountCargo(ctx context.Context, repositories dnfrepo.Group, catalog *pvfDungeonDropCatalog, config currentCargoTransportConfig, accountID, characterID string, request currentCargoTransportRequest) error {
|
|
if repositories.AccountItems == nil || repositories.Account == nil {
|
|
return errors.New("account cargo transaction unavailable")
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, accountID)
|
|
if err != nil || !found {
|
|
return firstNonNilError(err, errors.New("account missing"))
|
|
}
|
|
capacity := int(currentAccountCargoMetadataInt(account.Metadata, "account_cargo_level"))
|
|
if capacity <= 0 || capacity > math.MaxInt16 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorCargoInactive,
|
|
"account cargo not created: capacity=%d", capacity)
|
|
}
|
|
return repositories.AccountItems.WithinAccountCharacterItems(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, inventories dnfrepo.InventoryRepository) error {
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil || !found {
|
|
return firstNonNilError(loadErr, errors.New("source inventory missing"))
|
|
}
|
|
cargo, _, loadErr := accounts.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
cargo = dnfrepo.CloneAccountInventory(cargo)
|
|
cargo.AccountID = accountID
|
|
if cargo.Slots == nil {
|
|
cargo.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
stoneKey := currentMainInventorySlotKey(request.StoneSlot)
|
|
stone, found := inventory.Slots[stoneKey]
|
|
if !found || stone.ItemID <= 0 || stone.Count <= 0 || stone.ItemID > math.MaxUint32 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorItemMissing,
|
|
"cargo transport stone missing: slot=%d", request.StoneSlot)
|
|
}
|
|
stoneDefinition, resolveErr := catalog.ResolveItem(uint32(stone.ItemID))
|
|
if resolveErr != nil || stoneDefinition.Kind != dungeonDropItemStackable || !stoneDefinition.CargoTransportStone {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"cargo transport stone invalid: item=%d", stone.ItemID)
|
|
}
|
|
rule, found := config.Rules[stoneDefinition.CargoTransportStoneType]
|
|
if !found {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"cargo transport stone rule missing: type=%d", stoneDefinition.CargoTransportStoneType)
|
|
}
|
|
targetKey := currentMainInventorySlotKey(request.TargetSlot)
|
|
target, found := inventory.Slots[targetKey]
|
|
if !found || target.ItemID <= 0 || target.ItemID > math.MaxUint32 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorItemMissing,
|
|
"cargo transport target missing: slot=%d", request.TargetSlot)
|
|
}
|
|
targetDefinition, resolveErr := catalog.ResolveItem(uint32(target.ItemID))
|
|
if resolveErr != nil || !currentCargoRuleAllows(rule, targetDefinition, target) {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"cargo transport target ineligible: item=%d", target.ItemID)
|
|
}
|
|
destinationSlot := int16(-1)
|
|
for slot := 0; slot < capacity; slot++ {
|
|
if _, occupied := cargo.Slots[fmt.Sprintf("12:%d", slot)]; !occupied {
|
|
destinationSlot = int16(slot)
|
|
break
|
|
}
|
|
}
|
|
if destinationSlot < 0 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorCargoFull,
|
|
"account cargo full: capacity=%d", capacity)
|
|
}
|
|
delete(inventory.Slots, targetKey)
|
|
stone.Count--
|
|
if stone.Count == 0 {
|
|
delete(inventory.Slots, stoneKey)
|
|
} else {
|
|
inventory.Slots[stoneKey] = stone
|
|
}
|
|
cargo.Slots[fmt.Sprintf("12:%d", destinationSlot)] = target
|
|
now := s.gameplayNow().UTC()
|
|
inventory.UpdatedAt = now
|
|
cargo.UpdatedAt = now
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
return accounts.Save(ctx, cargo)
|
|
})
|
|
}
|
|
|
|
// commitCurrentGrandiEquipmentTransport moves one piece of equipment to
|
|
// another character on the same account and **delivers it by mail**, the way
|
|
// the official 歌兰蒂斯 跨界 does: dropping it straight into the recipient's
|
|
// backpack silently fails when that character is short of space, and gives no
|
|
// record of the transfer on either side.
|
|
//
|
|
// Mail delivery also narrows the transaction. The old two-character trade
|
|
// boundary existed only to write the destination inventory; the letter needs
|
|
// the sender's assets plus the recipient's mailbox, which is exactly
|
|
// WithinMailboxClaimAssets -- and it still covers the Epic Book, so the epic
|
|
// fragment cost commits or rolls back with the removal and the letter.
|
|
func (s *Service) commitCurrentGrandiEquipmentTransport(ctx context.Context, repositories dnfrepo.Group, catalog *pvfDungeonDropCatalog, config currentCargoTransportConfig, accountID, sourceCharacterID string, request currentCargoTransportRequest) error {
|
|
if repositories.MailboxAssets == nil || repositories.Character == nil {
|
|
return errors.New("equipment transport transaction unavailable")
|
|
}
|
|
claimAssets, ok := repositories.MailboxAssets.(dnfrepo.MailboxClaimAssetUnitOfWork)
|
|
if !ok {
|
|
return errors.New("equipment transport mailbox transaction unavailable")
|
|
}
|
|
destination, err := s.currentCargoRecipientBySelection(ctx, repositories.Character, accountID, sourceCharacterID, request.RecipientSelect)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rule, found := config.Rules[7]
|
|
if !found {
|
|
return errors.New("Grandi transport rule missing")
|
|
}
|
|
return claimAssets.WithinMailboxClaimAssets(ctx, sourceCharacterID, destination.CharacterID, func(
|
|
_ dnfrepo.AccountRepository,
|
|
accounts dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
mailboxes dnfrepo.MailboxRepository,
|
|
epicBooks dnfrepo.EpicBookRepository,
|
|
) error {
|
|
sourceCharacter, sourceFound, loadErr := characters.Load(ctx, sourceCharacterID)
|
|
if loadErr != nil || !sourceFound || strings.TrimSpace(sourceCharacter.AccountID) != accountID {
|
|
return firstNonNilError(loadErr, errors.New("source character mismatch"))
|
|
}
|
|
source, sourceFound, loadErr := inventories.Load(ctx, sourceCharacterID)
|
|
if loadErr != nil || !sourceFound {
|
|
return firstNonNilError(loadErr, errors.New("source inventory missing"))
|
|
}
|
|
account, _, loadErr := accounts.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
epicBook, _, loadErr := epicBooks.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
source = dnfrepo.CloneInventory(source)
|
|
account = dnfrepo.CloneAccountInventory(account)
|
|
epicBook = dnfrepo.CloneEpicBook(epicBook)
|
|
account.AccountID = accountID
|
|
epicBook.AccountID = accountID
|
|
targetKey := currentMainInventorySlotKey(request.TargetSlot)
|
|
target, targetFound := source.Slots[targetKey]
|
|
if !targetFound || target.ItemID <= 0 || target.ItemID > math.MaxUint32 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorItemMissing,
|
|
"equipment transport target missing: slot=%d", request.TargetSlot)
|
|
}
|
|
definition, resolveErr := catalog.ResolveItem(uint32(target.ItemID))
|
|
if resolveErr != nil || !currentCargoRuleAllows(rule, definition, target) {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"equipment transport target ineligible: item=%d", target.ItemID)
|
|
}
|
|
category := currentCargoEquipmentCategory(definition.EquipmentType)
|
|
if category < 0 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"equipment transport category unsupported: type=%q", definition.EquipmentType)
|
|
}
|
|
base := rangeWeightFor(rule.GradeWeights, definition.MinimumLevel) * rule.RarityWeights[definition.Rarity]
|
|
if base <= 0 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"equipment transport base cost invalid: item=%d", target.ItemID)
|
|
}
|
|
soulWeights := rule.SoulWeights[definition.Rarity]
|
|
if category >= len(soulWeights) || soulWeights[category] <= 0 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"equipment transport soul cost invalid: item=%d", target.ItemID)
|
|
}
|
|
soulID := rule.SoulItemIDs[definition.Rarity]
|
|
if soulID == 0 {
|
|
return currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"equipment transport soul missing: rarity=%d", definition.Rarity)
|
|
}
|
|
// The four cost checks below are the "所需材料不足" case the dialog has
|
|
// its own text for; anything else here is a rule fault, not the
|
|
// player being short.
|
|
if _, _, consumeErr := consumeCurrentItemByID(account.Slots, soulID, base*soulWeights[category]); consumeErr != nil {
|
|
return currentCargoTransportReject(currentCargoTransportErrorMaterial,
|
|
"equipment transport soul cost: item=%d need=%d: %v", soulID, base*soulWeights[category], consumeErr)
|
|
}
|
|
if elementWeights := rule.ElementWeights[definition.Rarity]; rule.ElementItemID > 0 && category < len(elementWeights) && elementWeights[category] > 0 {
|
|
if _, _, consumeErr := consumeCurrentItemByID(source.Slots, rule.ElementItemID, base*elementWeights[category]); consumeErr != nil {
|
|
return currentCargoTransportReject(currentCargoTransportErrorMaterial,
|
|
"equipment transport element cost: item=%d need=%d: %v", rule.ElementItemID, base*elementWeights[category], consumeErr)
|
|
}
|
|
}
|
|
if category < len(rule.EpicPieceWeight) && rule.EpicPieceWeight[category] > 0 {
|
|
need := uint64(base) * uint64(rule.EpicPieceWeight[category])
|
|
balance := uint64(epicBook.Balances[uint32(target.ItemID)])
|
|
if epicBook.Balances == nil || need > balance {
|
|
return currentCargoTransportReject(currentCargoTransportErrorMaterial,
|
|
"epic fragment balance insufficient: item=%d need=%d have=%d", target.ItemID, need, balance)
|
|
}
|
|
epicBook.Balances[uint32(target.ItemID)] = uint32(balance - need)
|
|
}
|
|
for _, particular := range rule.ParticularCosts {
|
|
if particular.Rarity == definition.Rarity && particular.Level == definition.MinimumLevel {
|
|
if _, _, consumeErr := consumeCurrentItemByID(source.Slots, particular.ItemID, particular.Count); consumeErr != nil {
|
|
return currentCargoTransportReject(currentCargoTransportErrorMaterial,
|
|
"equipment transport particular cost: item=%d need=%d: %v", particular.ItemID, particular.Count, consumeErr)
|
|
}
|
|
}
|
|
}
|
|
now := s.gameplayNow().UTC()
|
|
delete(source.Slots, targetKey)
|
|
if _, mailErr := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
|
|
RecipientCharacterID: destination.CharacterID,
|
|
RecipientName: destination.Name,
|
|
Title: "装备跨界",
|
|
Body: fmt.Sprintf("%s 通过歌兰蒂斯的装备跨界寄送了一件装备,请在附件中领取。",
|
|
currentCargoTransportSenderName(sourceCharacter, sourceCharacterID)),
|
|
Source: "grandi_equipment_transport",
|
|
Attachments: []dnfrepo.MailAttachment{currentCargoTransportMailAttachment(target)},
|
|
CreatedAt: now,
|
|
}); mailErr != nil {
|
|
return mailErr
|
|
}
|
|
source.UpdatedAt, account.UpdatedAt, epicBook.UpdatedAt = now, now, now
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, source, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if saveErr := accounts.Save(ctx, account); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
return epicBooks.Save(ctx, epicBook)
|
|
})
|
|
}
|
|
|
|
// currentCargoTransportMailAttachment carries the exact equipment instance --
|
|
// its packed row, reinforcement/amplification extras, binding and expiry --
|
|
// into the letter. A cross-transfer must not silently reset the piece to a
|
|
// fresh template the way a template-id attachment would.
|
|
func currentCargoTransportMailAttachment(target dnfrepo.ItemStack) dnfrepo.MailAttachment {
|
|
count := target.Count
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
extra := make(map[string]string, len(target.Extra)+2)
|
|
for key, value := range target.Extra {
|
|
extra[key] = value
|
|
}
|
|
extra["source"] = "grandi_equipment_transport"
|
|
extra["last_grant_source"] = "grandi_equipment_transport"
|
|
return dnfrepo.MailAttachment{
|
|
ItemID: target.ItemID,
|
|
Count: count,
|
|
Bind: target.Bind,
|
|
ExpireAt: target.ExpireAt,
|
|
RawEntry: append([]byte(nil), target.RawEntry...),
|
|
Extra: extra,
|
|
}
|
|
}
|
|
|
|
func currentCargoTransportSenderName(sender dnfrepo.CharacterRecord, fallbackID string) string {
|
|
if name := strings.TrimSpace(sender.Name); name != "" {
|
|
return name
|
|
}
|
|
return "角色 " + fallbackID
|
|
}
|
|
|
|
func (s *Service) commitCurrentPetTransport(ctx context.Context, repositories dnfrepo.Group, catalog *pvfDungeonDropCatalog, accountID, sourceCharacterID string, request currentCargoTransportRequest) error {
|
|
if repositories.AccountCharacterTrade == nil || repositories.Character == nil {
|
|
return errors.New("pet transport transaction unavailable")
|
|
}
|
|
destination, err := s.currentCargoRecipientBySelection(ctx, repositories.Character, accountID, sourceCharacterID, request.RecipientSelect)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return repositories.AccountCharacterTrade.WithinAccountCharacterTrade(ctx, accountID, sourceCharacterID, destination.CharacterID, func(_ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository, _ dnfrepo.EpicBookRepository, pets dnfrepo.PetRepository) error {
|
|
for _, characterID := range []string{sourceCharacterID, destination.CharacterID} {
|
|
character, found, loadErr := characters.Load(ctx, characterID)
|
|
if loadErr != nil || !found || strings.TrimSpace(character.AccountID) != accountID {
|
|
return firstNonNilError(loadErr, errors.New("pet transport character mismatch"))
|
|
}
|
|
}
|
|
source, found, loadErr := inventories.Load(ctx, sourceCharacterID)
|
|
if loadErr != nil || !found {
|
|
return firstNonNilError(loadErr, errors.New("pet transport source inventory missing"))
|
|
}
|
|
dest, _, loadErr := inventories.Load(ctx, destination.CharacterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
sourcePets, _, loadErr := pets.Load(ctx, sourceCharacterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
destPets, _, loadErr := pets.Load(ctx, destination.CharacterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
source = dnfrepo.CloneInventory(source)
|
|
dest = dnfrepo.CloneInventory(dest)
|
|
sourcePets = dnfrepo.ClonePet(sourcePets)
|
|
destPets = dnfrepo.ClonePet(destPets)
|
|
dest.CharacterID = destination.CharacterID
|
|
sourcePets.CharacterID = sourceCharacterID
|
|
destPets.CharacterID = destination.CharacterID
|
|
if dest.Slots == nil {
|
|
dest.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
stoneKey := currentMainInventorySlotKey(request.StoneSlot)
|
|
stone, found := source.Slots[stoneKey]
|
|
if !found || stone.ItemID <= 0 || stone.ItemID > math.MaxUint32 || stone.Count <= 0 {
|
|
return errors.New("pet transport stone missing")
|
|
}
|
|
stoneDefinition, resolveErr := catalog.ResolveItem(uint32(stone.ItemID))
|
|
if resolveErr != nil || !stoneDefinition.CreatureTransportStone {
|
|
return errors.New("pet transport stone invalid")
|
|
}
|
|
targetKey := fmt.Sprintf("7:%d", request.TargetSlot)
|
|
target, found := source.Slots[targetKey]
|
|
if !found || target.ItemID <= 0 || target.ItemID > math.MaxUint32 {
|
|
return errors.New("pet transport target missing")
|
|
}
|
|
targetDefinition, resolveErr := catalog.ResolveItem(uint32(target.ItemID))
|
|
if resolveErr != nil || normalizeCargoEquipmentType(targetDefinition.EquipmentType) != "creature" {
|
|
return errors.New("pet transport target invalid")
|
|
}
|
|
var sourcePetKey string
|
|
var sourcePet dnfrepo.PetEntry
|
|
for petKey, entry := range sourcePets.Entries {
|
|
if entry.SourceListType == 7 && entry.SourceSlotIndex == request.TargetSlot && entry.ItemID == target.ItemID {
|
|
sourcePetKey, sourcePet = petKey, entry
|
|
break
|
|
}
|
|
}
|
|
if sourcePetKey == "" || sourcePets.EquippedKey == sourcePetKey {
|
|
return errors.New("pet transport durable state unavailable or equipped")
|
|
}
|
|
destinationSlot := int16(-1)
|
|
for slot := int16(0); slot <= 139; slot++ {
|
|
if _, occupied := dest.Slots[fmt.Sprintf("7:%d", slot)]; !occupied {
|
|
destinationSlot = slot
|
|
break
|
|
}
|
|
}
|
|
if destinationSlot < 0 {
|
|
return errors.New("destination pet inventory full")
|
|
}
|
|
if destPets.Entries == nil {
|
|
destPets.Entries = make(map[string]dnfrepo.PetEntry)
|
|
}
|
|
if _, collision := destPets.Entries[sourcePetKey]; collision {
|
|
return errors.New("destination pet identity collision")
|
|
}
|
|
delete(source.Slots, targetKey)
|
|
delete(sourcePets.Entries, sourcePetKey)
|
|
stone.Count--
|
|
if stone.Count == 0 {
|
|
delete(source.Slots, stoneKey)
|
|
} else {
|
|
source.Slots[stoneKey] = stone
|
|
}
|
|
dest.Slots[fmt.Sprintf("7:%d", destinationSlot)] = target
|
|
sourcePet.SourceSlotIndex = destinationSlot
|
|
destPets.Entries[sourcePetKey] = sourcePet
|
|
now := s.gameplayNow().UTC()
|
|
source.UpdatedAt, dest.UpdatedAt, sourcePets.UpdatedAt, destPets.UpdatedAt = now, now, now, now
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, source, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, dest, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if saveErr := dnfrepo.SavePetFields(ctx, pets, sourcePets, dnfrepo.PetFieldEntries); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
return dnfrepo.SavePetFields(ctx, pets, destPets, dnfrepo.PetFieldEntries)
|
|
})
|
|
}
|
|
|
|
// currentCargoRecipientBySelection resolves the recipient the client picked in
|
|
// the 装备跨界 dropdown. The value on the wire is the **position** of the
|
|
// character in the client's own roster array, not its slot: the combo is
|
|
// filled at 0x107DA0B..0x107DA8E by walking that array with `edi` and storing
|
|
// `edi` itself as each row's data (`push edi` at 0x107DA79), skipping the
|
|
// acting character's own row but not its index; the request then sends the
|
|
// selected row's data (`sub_353C2E0` reads combo+0x3D8, which
|
|
// `sub_353C96C`/`sub_353CA32` fill from the selected row's +0x420).
|
|
// ServerS4A21's InventoryHandler.CargoTransport.cs:158-174 resolves the same
|
|
// field the same way.
|
|
//
|
|
// The roster array is what listCharacters/ListByAccount produce: non-deleted
|
|
// characters ordered by slot then character id, densely packed. Index and slot
|
|
// coincide only while the slots run 0..n-1, which is why matching on Slot --
|
|
// the previous behaviour -- worked on a full account and silently failed for
|
|
// anyone whose slots have a gap.
|
|
func (s *Service) currentCargoRecipientBySelection(ctx context.Context, characters dnfrepo.CharacterRepository, accountID, sourceCharacterID string, selection uint32) (dnfrepo.CharacterRecord, error) {
|
|
rows, err := characters.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
|
|
if err != nil {
|
|
return dnfrepo.CharacterRecord{}, err
|
|
}
|
|
roster := make([]dnfrepo.CharacterRecord, 0, len(rows))
|
|
for _, row := range rows {
|
|
if strings.TrimSpace(row.AccountID) == accountID {
|
|
roster = append(roster, row)
|
|
}
|
|
}
|
|
sort.SliceStable(roster, func(left, right int) bool {
|
|
if roster[left].Slot == roster[right].Slot {
|
|
return roster[left].CharacterID < roster[right].CharacterID
|
|
}
|
|
return roster[left].Slot < roster[right].Slot
|
|
})
|
|
if selection > math.MaxInt32 || int(selection) >= len(roster) {
|
|
return dnfrepo.CharacterRecord{}, currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"same-account recipient missing: selection=%d roster=%d", selection, len(roster))
|
|
}
|
|
picked := roster[selection]
|
|
if picked.CharacterID == sourceCharacterID {
|
|
return dnfrepo.CharacterRecord{}, currentCargoTransportReject(currentCargoTransportErrorNotTransferable,
|
|
"same-account recipient is the sender: selection=%d", selection)
|
|
}
|
|
if s != nil && picked.Slot != int(selection) {
|
|
// Worth a line: on a gap-free account the two readings agree, so any
|
|
// divergence is the first live evidence for the index reading.
|
|
s.logPacketEvent("dnf-cargo-transport-recipient-index-differs-from-slot",
|
|
"selection", selection, "picked_character_id", picked.CharacterID, "picked_slot", picked.Slot)
|
|
}
|
|
return picked, nil
|
|
}
|
|
|
|
func currentCargoRuleAllows(rule currentCargoTransportRule, definition dungeonDropItemDefinition, stack dnfrepo.ItemStack) bool {
|
|
if definition.ItemID == 0 || definition.Kind != dungeonDropItemEquipment || rule.ExcludeItemIDs[definition.ItemID] {
|
|
return false
|
|
}
|
|
// 跨界一次搬一件:装备一件一行,Count != 1 只可能是魔盒发奖缺陷留下的历史堆叠行
|
|
// (现场 2026-09-27 线上 90 行 357 件)。跨界石按件计价、寄送附件按 stack.Count 整行
|
|
// 带走,放它进来就是「一颗跨界石/一次运费搬走 N 件」。
|
|
if !currentStackIsSingleEquipment(stack) {
|
|
return false
|
|
}
|
|
if len(rule.IncludeItemIDs) > 0 && !rule.IncludeItemIDs[definition.ItemID] {
|
|
return false
|
|
}
|
|
if len(rule.EquipmentTypes) > 0 && !rule.EquipmentTypes[normalizeCargoEquipmentType(definition.EquipmentType)] {
|
|
return false
|
|
}
|
|
if len(rule.GradeWeights) > 0 && rangeWeightFor(rule.GradeWeights, definition.MinimumLevel) <= 0 {
|
|
return false
|
|
}
|
|
if len(rule.RarityWeights) > 0 && rule.RarityWeights[definition.Rarity] <= 0 {
|
|
return false
|
|
}
|
|
if rule.ExceptGrowth && definition.HasGrowthType || rule.ExceptPeriod && (definition.PeriodEquipment || !stack.ExpireAt.IsZero()) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func currentCargoEquipmentCategory(equipmentType string) int {
|
|
switch normalizeCargoEquipmentType(equipmentType) {
|
|
case "weapon":
|
|
return 0
|
|
case "coat", "shoulder", "pants", "shoes", "waist":
|
|
return 1
|
|
case "amulet", "wrist", "ring":
|
|
return 2
|
|
case "support", "magic stone", "earring":
|
|
return 3
|
|
case "title name":
|
|
return 4
|
|
default:
|
|
return -1
|
|
}
|
|
}
|