服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1886 lines
70 KiB
Go
1886 lines
70 KiB
Go
package mail
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/carrygold"
|
||
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
||
"longheng.io/server/internal/modules/dnf/premium"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
||
)
|
||
|
||
var (
|
||
ErrMailboxRepoMissing = errors.New("mailbox repository is missing")
|
||
ErrMailboxCharacterMissing = errors.New("mailbox character is missing")
|
||
ErrMailboxRecipientMissing = errors.New("mailbox recipient is missing")
|
||
ErrMailboxInvalidRequest = errors.New("mailbox request is invalid")
|
||
ErrMailboxMailNotFound = errors.New("mailbox mail is not found")
|
||
ErrMailboxAlreadyClaimed = errors.New("mailbox mail is already claimed")
|
||
ErrMailboxExpired = errors.New("mailbox mail expired")
|
||
ErrMailboxInventoryFull = errors.New("mailbox inventory is full")
|
||
ErrMailboxAssetsUOW = errors.New("mailbox asset unit of work is missing")
|
||
ErrMailboxAssetMismatch = errors.New("mailbox attachment does not match durable inventory")
|
||
ErrMailboxGoldInsufficient = errors.New("mailbox sender gold is insufficient")
|
||
ErrMailboxDeleteClaimable = errors.New("mailbox mail still has claimable assets")
|
||
ErrMailboxStateUnsupported = errors.New("mailbox state is unsupported")
|
||
ErrMailboxSpecialUnsupported = errors.New("special mailbox send is unsupported")
|
||
ErrMailboxItemResolverMissing = errors.New("mailbox item price resolver is missing")
|
||
ErrMailboxSelfRecipient = errors.New("mailbox recipient is the sender")
|
||
// ErrMailboxItemLocked marks a locked attachment. The equipment lock's whole
|
||
// purpose is to keep an item out of destructive or irreversible moves, and a
|
||
// letter leaves the character for good - live 2026-09-15: a locked piece of
|
||
// equipment was mailed away because this path had no lock check at all.
|
||
ErrMailboxItemLocked = errors.New("mailbox attachment is equipment locked")
|
||
ErrMailboxSavedFull = errors.New("mailbox saved-letter storage is full")
|
||
)
|
||
|
||
// mailboxStackLocked mirrors the lock-state contract the itemlock owner writes
|
||
// (equipment_lock_state: "1"/"2" or a named state). Selling, discarding, trading
|
||
// and every upgrade-style path each keep their own copy of that switch, so this
|
||
// one follows the same shape instead of inventing a shared abstraction.
|
||
func mailboxStackLocked(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
|
||
}
|
||
}
|
||
|
||
const (
|
||
mailboxNormalPageSize = 20
|
||
mailboxSavedPageSize = 10
|
||
mailboxNormalLifetime = 15 * 24 * time.Hour
|
||
|
||
// Mail attachments may target the character bag (3..353) or the
|
||
// account-owned crystal/soul warehouses (354..365). The resolver's PVF
|
||
// range decides the durable owner; this upper bound only validates the
|
||
// complete client-visible main-inventory namespace.
|
||
mailboxMainFirstItemSlot int16 = 3
|
||
mailboxMainLastItemSlot int16 = dnfrepo.SoulWarehouseLastSlot
|
||
|
||
// 主背包标准分类的解锁口径:每个分类 7 行 × 8 格 = 56 格,前 32 格随角色解锁,
|
||
// 之后按 CharacterContainerState.MainSlotCount(0/8/16/24)逐行开放。
|
||
// 与 dnfbridge.currentMainInventoryBaseSlotCount /
|
||
// currentMainInventoryCategorySlotCount、inventory.moveDestinationUnlockedSlotRange
|
||
// 是同一套数字;主背包扩展最多 3 行(24 格),超出说明状态不可信,按"未知"处理。
|
||
mailboxMainInventoryBaseSlotCount int16 = 32
|
||
mailboxMainInventoryCategorySlotCount int16 = 56
|
||
mailboxMainInventoryMaxExpansionSlots uint16 = 24
|
||
|
||
// Current PVF stackable/cash/chn_Trade_Gold/trade_point_event.stk is an
|
||
// account-currency voucher. It must never occupy a material inventory slot.
|
||
HappyTokenCeraVoucherItemID int64 = 2681917
|
||
)
|
||
|
||
type Owner struct {
|
||
repos dnfrepo.Group
|
||
itemResolver alignedcmd.MailboxItemResolver
|
||
premiumResolver alignedcmd.PremiumContractResolver
|
||
premiumAccountID string
|
||
now func() time.Time
|
||
}
|
||
|
||
// WithPremiumContracts lets the extraction path activate a 契约 attachment
|
||
// instead of demanding a backpack slot for it. Both the runtime-PVF resolver and
|
||
// the acting account are required: the resolver is the only authority for the
|
||
// premium type and its term, and the expiry is account-scoped state.
|
||
func (o Owner) WithPremiumContracts(resolver alignedcmd.PremiumContractResolver, accountID string) Owner {
|
||
o.premiumResolver = resolver
|
||
o.premiumAccountID = strings.TrimSpace(accountID)
|
||
return o
|
||
}
|
||
|
||
// resolvePremiumContract reports whether one attachment is a runtime-PVF premium
|
||
// contract. Without a resolver or an acting account the attachment keeps its
|
||
// ordinary item behaviour instead of being silently consumed.
|
||
func (o Owner) resolvePremiumContract(itemID int64) (alignedcmd.PremiumContractResolution, bool, error) {
|
||
if o.premiumResolver == nil || o.premiumAccountID == "" || itemID <= 0 {
|
||
return alignedcmd.PremiumContractResolution{}, false, nil
|
||
}
|
||
resolution, err := o.premiumResolver(itemID)
|
||
if err != nil {
|
||
return alignedcmd.PremiumContractResolution{}, false, err
|
||
}
|
||
if resolution.PremiumType <= 0 || resolution.DurationSeconds <= 0 {
|
||
return alignedcmd.PremiumContractResolution{}, false, nil
|
||
}
|
||
return resolution, true, nil
|
||
}
|
||
|
||
type OpenResult struct {
|
||
CharacterID string
|
||
Total int
|
||
Unread int
|
||
Claimable int
|
||
NotLoaded int
|
||
Mails []dnfrepo.MailRecord
|
||
ObservedAt time.Time
|
||
}
|
||
|
||
type SendResult struct {
|
||
MailID string
|
||
RecipientCharacterID string
|
||
}
|
||
|
||
type ClaimResult struct {
|
||
MailIDs []uint32
|
||
Gold int64
|
||
GoldAfter int64
|
||
// GoldDiscarded is the part of the attachment the carry cap refused.
|
||
GoldDiscarded int64
|
||
ItemCount int
|
||
HappyTokenCeraGranted int64
|
||
// TokenCeraGranted 是本次领取里直接入钱包的代币券数量(与欢乐代币券同一套语义)。
|
||
TokenCeraGranted int64
|
||
Wallet *dnfcerashop.Wallet
|
||
InventoryOK bool
|
||
ItemSlotRefreshes []alignedcmd.ItemSlotRefresh
|
||
EpicBookBalances []alignedcmd.EpicBookBalanceProjection
|
||
// Claimed lists what op95 reports back: one row per attachment slot actually
|
||
// handed over, carrying the same claim object the summary row gave it.
|
||
Claimed []ClaimedAttachment
|
||
PremiumActivated bool
|
||
// PremiumActivations is what a claimed 契约 granted, in premium-type order.
|
||
// The account row already carries these expiries; the client does not, and
|
||
// only learns about them from the activation notification.
|
||
PremiumActivations []PremiumActivation
|
||
}
|
||
|
||
// PremiumActivation is one entitlement a claim extended. The absolute expiry is
|
||
// what the account row holds; the remaining seconds are what the activation
|
||
// notification carries, and both are taken from the claim's own clock.
|
||
type PremiumActivation struct {
|
||
PremiumType int64
|
||
ExpireAt int64
|
||
RemainingSeconds int64
|
||
}
|
||
|
||
// ClaimedAttachment addresses one attachment the client must retire from an open
|
||
// letter. The client needs both halves: the letter id keys its mailbox container
|
||
// and the claim object selects the slot inside that letter.
|
||
type ClaimedAttachment struct {
|
||
LetterID uint32
|
||
ClaimObject uint32
|
||
}
|
||
|
||
// pendingPremiumContract is one staged 契约 activation for a single letter.
|
||
type pendingPremiumContract struct {
|
||
resolution alignedcmd.PremiumContractResolution
|
||
count int64
|
||
}
|
||
|
||
// ClearAllResult reports the durable rows and still-visible assets removed
|
||
// from one character's mailbox. Clearing is serialized with mail claiming so
|
||
// an attachment cannot be claimed while the GM operation removes its letter.
|
||
type ClearAllResult struct {
|
||
Removed int
|
||
Unread int
|
||
Claimable int
|
||
Gold int64
|
||
Attachments int
|
||
}
|
||
|
||
func NewOwner(repos dnfrepo.Group, itemResolvers ...alignedcmd.MailboxItemResolver) Owner {
|
||
var itemResolver alignedcmd.MailboxItemResolver
|
||
if len(itemResolvers) > 0 {
|
||
itemResolver = itemResolvers[0]
|
||
}
|
||
return Owner{repos: repos, itemResolver: itemResolver, now: time.Now}
|
||
}
|
||
|
||
// ClearAll permanently removes every normal, saved, deleted, and expired
|
||
// letter from the character's durable mailbox.
|
||
func (o Owner) ClearAll(ctx context.Context, characterID string) (ClearAllResult, error) {
|
||
characterID = strings.TrimSpace(characterID)
|
||
if characterID == "" {
|
||
return ClearAllResult{}, ErrMailboxCharacterMissing
|
||
}
|
||
if o.repos.Mailbox == nil {
|
||
return ClearAllResult{}, ErrMailboxRepoMissing
|
||
}
|
||
if o.repos.MailboxAssets == nil {
|
||
return ClearAllResult{}, ErrMailboxAssetsUOW
|
||
}
|
||
result := ClearAllResult{}
|
||
err := o.repos.MailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
_ dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
_ dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
record, found, err := mailboxes.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found || len(record.Mails) == 0 {
|
||
return nil
|
||
}
|
||
now := o.clock()
|
||
result.Removed = len(record.Mails)
|
||
for _, candidate := range record.Mails {
|
||
visible := !candidate.Deleted && !mailExpired(candidate, now)
|
||
if visible && !candidate.Read {
|
||
result.Unread++
|
||
}
|
||
if visible && !candidate.Claimed && (candidate.Gold > 0 || len(candidate.Attachments) > 0) {
|
||
result.Claimable++
|
||
}
|
||
if !candidate.Claimed {
|
||
result.Gold += candidate.Gold
|
||
result.Attachments += len(candidate.Attachments)
|
||
}
|
||
}
|
||
record.Mails = nil
|
||
record.UpdatedAt = now
|
||
return dnfrepo.SaveMailboxFields(ctx, mailboxes, record, dnfrepo.MailboxFieldMails)
|
||
})
|
||
if err != nil {
|
||
return ClearAllResult{}, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (o Owner) Open(ctx context.Context, characterID string) (OpenResult, error) {
|
||
characterID = strings.TrimSpace(characterID)
|
||
if characterID == "" {
|
||
return OpenResult{}, ErrMailboxCharacterMissing
|
||
}
|
||
if o.repos.Mailbox == nil {
|
||
return OpenResult{}, ErrMailboxRepoMissing
|
||
}
|
||
record, _, err := o.loadOrCreateMailbox(ctx, characterID)
|
||
if err != nil {
|
||
return OpenResult{}, err
|
||
}
|
||
// Older claim implementations retained transferred assets on a claimed
|
||
// letter. A claimed mail is an empty, read receipt: repair those legacy
|
||
// rows before projecting the mailbox so the client cannot render or select
|
||
// a stale attachment again.
|
||
if clearClaimedMailboxAssets(&record) {
|
||
record.UpdatedAt = o.clock()
|
||
if err := dnfrepo.SaveMailboxFields(ctx, o.repos.Mailbox, record, dnfrepo.MailboxFieldMails); err != nil {
|
||
return OpenResult{}, err
|
||
}
|
||
}
|
||
result := OpenResult{CharacterID: characterID}
|
||
now := o.clock()
|
||
result.ObservedAt = now
|
||
normal := make([]dnfrepo.MailRecord, 0, len(record.Mails))
|
||
saved := make([]dnfrepo.MailRecord, 0, len(record.Mails))
|
||
for _, mail := range record.Mails {
|
||
if mail.Deleted || mailExpired(mail, now) {
|
||
continue
|
||
}
|
||
result.Total++
|
||
if !mail.Read {
|
||
result.Unread++
|
||
}
|
||
if !mail.Claimed && (mail.Gold > 0 || len(mail.Attachments) > 0) {
|
||
result.Claimable++
|
||
}
|
||
if mailboxMailSaved(mail) {
|
||
saved = append(saved, cloneMailboxMail(mail))
|
||
} else {
|
||
normal = append(normal, cloneMailboxMail(mail))
|
||
}
|
||
}
|
||
sortMailboxMails(normal)
|
||
sortMailboxMails(saved)
|
||
// 分页窗口取**最新**的一页,不是最老的一页。
|
||
//
|
||
// 这一页就是玩家能看到的全部邮件:协议里没有任何"加载更多 / 翻页"请求
|
||
// (request.go 只有 send / 领取 / 改状态 / 查角色 / mail-id 列表),
|
||
// not_loaded 只是个计数,客户端拿它画一行提示、不会回请下一页。
|
||
// 换句话说,谁被截掉,谁就等于不存在。
|
||
//
|
||
// sortMailboxMails 是**升序**(created_at 老的在前,同一时刻按 mail_id 升序),
|
||
// 所以老的 normal[:mailboxNormalPageSize] 取的是**最老的 20 封**。一旦邮箱里
|
||
// 活着的普通邮件超过 20 封,这个窗口就被最老的那一批永久钉死:此后进来的每一封
|
||
// 信都只会让 not_loaded +1,永远挤不进窗口。而且它还锁死了自己——删信要求先领取
|
||
// (见 ChangeState 的 ErrMailboxDeleteClaimable),领取又要求背包有空位,于是
|
||
// "满背包 → 奖励转邮件 → 邮件领不出来也删不掉 → 窗口永不前进"。
|
||
//
|
||
// 实机证据(2026-09-23,角色 5「随便玩玩」):451 封活着,窗口恒为 mail_id
|
||
// 516..535;GM 新发一封 966 成功落库,窗口纹丝不动 —— 玩家那边看到的就是
|
||
// "收不到邮件"。alert 也救不回来:class0/0x63 只弹一句"收到新邮件"、从不重载
|
||
// 列表(见 dnfbridge/mailbox_notification.go),列表只由 class0/0x61 重建,
|
||
// 而重建出来的还是那 20 封最老的。
|
||
//
|
||
// 截尾取最新即可:窗口跟着最新邮件走,Total/Unread/Claimable 仍是全量统计,
|
||
// not_loaded 的口径(有多少封没装进这一页)也完全没变。
|
||
//
|
||
// 注意:不要把这里改成 sortMailboxMails 反向排序。发出去的顺序仍是升序,
|
||
// 客户端 FUN_0270b7f0 自己会重排容器;改顺序既解决不了截断,又会动到
|
||
// 已经对齐过的字节布局。真正要修的只有"截哪一头"。
|
||
if len(normal) > mailboxNormalPageSize {
|
||
result.NotLoaded += len(normal) - mailboxNormalPageSize
|
||
normal = normal[len(normal)-mailboxNormalPageSize:]
|
||
}
|
||
if len(saved) > mailboxSavedPageSize {
|
||
result.NotLoaded += len(saved) - mailboxSavedPageSize
|
||
saved = saved[len(saved)-mailboxSavedPageSize:]
|
||
}
|
||
result.Mails = append(normal, saved...)
|
||
return result, nil
|
||
}
|
||
|
||
func (o Owner) Send(ctx context.Context, senderCharacterID string, req SendRequest) (SendResult, error) {
|
||
senderCharacterID = strings.TrimSpace(senderCharacterID)
|
||
if senderCharacterID == "" {
|
||
return SendResult{}, ErrMailboxCharacterMissing
|
||
}
|
||
if strings.TrimSpace(req.RecipientName) == "" {
|
||
return SendResult{}, ErrMailboxInvalidRequest
|
||
}
|
||
// The current client sets the trailing global flag on an ordinary send.
|
||
// Live 2026-09-15: a letter to a character on another account arrived as
|
||
// special=0 global=1 and was refused as "special mailbox send is
|
||
// unsupported", so no letter could be sent at all - locked or not. The flag
|
||
// marks a recipient outside the sender's own account, not a server-wide
|
||
// announcement, so only a non-zero Special still means an unimplemented
|
||
// special mail.
|
||
if req.Special != 0 {
|
||
return SendResult{}, ErrMailboxSpecialUnsupported
|
||
}
|
||
if req.Gold > math.MaxInt32 {
|
||
return SendResult{}, ErrMailboxInvalidRequest
|
||
}
|
||
if o.repos.Character == nil {
|
||
return SendResult{}, ErrMailboxCharacterMissing
|
||
}
|
||
if o.repos.Mailbox == nil {
|
||
return SendResult{}, ErrMailboxRepoMissing
|
||
}
|
||
if o.repos.MailboxAssets == nil {
|
||
return SendResult{}, ErrMailboxAssetsUOW
|
||
}
|
||
|
||
recipientID, found, err := o.repos.Character.FindIDByName(ctx, req.RecipientName)
|
||
if err != nil {
|
||
return SendResult{}, err
|
||
}
|
||
if !found {
|
||
return SendResult{}, ErrMailboxRecipientMissing
|
||
}
|
||
if recipientID == senderCharacterID {
|
||
return SendResult{}, ErrMailboxSelfRecipient
|
||
}
|
||
var mailID string
|
||
err = o.repos.MailboxAssets.WithinMailboxAssets(
|
||
ctx,
|
||
senderCharacterID,
|
||
recipientID,
|
||
func(
|
||
_ dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventories dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
sender, senderFound, loadErr := characters.Load(ctx, senderCharacterID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !senderFound {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
if sender.Stats == nil {
|
||
sender.Stats = make(map[string]int64)
|
||
}
|
||
gold := int64(req.Gold)
|
||
|
||
var inventory dnfrepo.InventoryRecord
|
||
var inventoryFound bool
|
||
if len(req.Attachments) > 0 {
|
||
inventory, inventoryFound, loadErr = inventories.Load(ctx, senderCharacterID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !inventoryFound || inventory.Slots == nil {
|
||
return ErrMailboxAssetMismatch
|
||
}
|
||
}
|
||
attachments := make([]dnfrepo.MailAttachment, 0, len(req.Attachments))
|
||
var attachmentValue int64
|
||
for idx, requested := range req.Attachments {
|
||
if requested.Count > math.MaxInt32 {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
key := fmt.Sprintf("%d:%d", requested.ListType, requested.SlotIndex)
|
||
stack, ok := inventory.Slots[key]
|
||
if !ok || stack.ItemID != int64(requested.ItemID) || stack.Count < int64(requested.Count) {
|
||
return fmt.Errorf("%w: attachment=%d list=%d slot=%d", ErrMailboxAssetMismatch, idx, requested.ListType, requested.SlotIndex)
|
||
}
|
||
if mailboxStackLocked(stack) {
|
||
return fmt.Errorf("%w: attachment=%d list=%d slot=%d item=%d",
|
||
ErrMailboxItemLocked, idx, requested.ListType, requested.SlotIndex, stack.ItemID)
|
||
}
|
||
if o.itemResolver == nil {
|
||
return ErrMailboxItemResolverMissing
|
||
}
|
||
resolution, resolveErr := o.itemResolver(requested.ItemID)
|
||
if resolveErr != nil || resolution.Price < 0 {
|
||
return fmt.Errorf("%w: attachment=%d item=%d resolve=%v", ErrMailboxAssetMismatch, idx, requested.ItemID, resolveErr)
|
||
}
|
||
count := int64(requested.Count)
|
||
if resolution.Price > 0 && count > math.MaxInt64/resolution.Price {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
value := resolution.Price * count
|
||
if attachmentValue > math.MaxInt64-value {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
attachmentValue += value
|
||
attachment := dnfrepo.MailAttachment{
|
||
ItemID: stack.ItemID,
|
||
Count: count,
|
||
Bind: stack.Bind,
|
||
ExpireAt: stack.ExpireAt,
|
||
RawEntry: append([]byte(nil), stack.RawEntry...),
|
||
Extra: cloneStringMap(stack.Extra),
|
||
}
|
||
if attachment.Extra == nil {
|
||
attachment.Extra = make(map[string]string)
|
||
}
|
||
attachment.Extra["mailbox_pvf_kind"] = resolution.Kind
|
||
attachment.Extra["mailbox_equipment_type"] = resolution.EquipmentType
|
||
attachment.Extra["mailbox_pvf_path"] = resolution.PVFPath
|
||
attachments = append(attachments, attachment)
|
||
if stack.Count == count {
|
||
delete(inventory.Slots, key)
|
||
} else {
|
||
stack.Count -= count
|
||
inventory.Slots[key] = stack
|
||
}
|
||
}
|
||
postage, postageErr := currentMailboxPostage(gold, len(attachments), attachmentValue)
|
||
if postageErr != nil || gold > math.MaxInt64-postage {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
totalDebit := gold + postage
|
||
if sender.Stats["gold"] < totalDebit {
|
||
return ErrMailboxGoldInsufficient
|
||
}
|
||
|
||
record, recordFound, loadErr := mailboxes.Load(ctx, recipientID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !recordFound {
|
||
record = dnfrepo.MailboxRecord{CharacterID: recipientID}
|
||
}
|
||
if record.Mails == nil {
|
||
record.Mails = make(map[string]dnfrepo.MailRecord)
|
||
}
|
||
mailID, loadErr = nextMailID(record.Mails)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
now := o.clock()
|
||
record.Mails[mailID] = dnfrepo.MailRecord{
|
||
MailID: mailID,
|
||
SenderCharacterID: senderCharacterID,
|
||
SenderName: sender.Name,
|
||
RecipientCharacterID: recipientID,
|
||
RecipientName: req.RecipientName,
|
||
Body: req.Body,
|
||
Gold: gold,
|
||
Attachments: attachments,
|
||
CreatedAt: now,
|
||
ExpireAt: now.Add(mailboxNormalLifetime),
|
||
Metadata: map[string]string{
|
||
"source": mailboxSendSource(req),
|
||
"special": strconv.FormatUint(uint64(req.Special), 10),
|
||
"global": strconv.FormatBool(req.Global),
|
||
"postage_gold": strconv.FormatInt(postage, 10),
|
||
},
|
||
}
|
||
record.UpdatedAt = now
|
||
sender.Stats["gold"] -= totalDebit
|
||
if err := dnfrepo.SaveCharacterFields(ctx, characters, sender, dnfrepo.CharacterFieldStats); err != nil {
|
||
return err
|
||
}
|
||
if len(req.Attachments) > 0 {
|
||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return dnfrepo.SaveMailboxFields(ctx, mailboxes, record, dnfrepo.MailboxFieldMails)
|
||
},
|
||
)
|
||
if err != nil {
|
||
return SendResult{}, err
|
||
}
|
||
return SendResult{MailID: mailID, RecipientCharacterID: recipientID}, nil
|
||
}
|
||
|
||
func (o Owner) Claim(ctx context.Context, characterID string, req ExtractRequest) (ClaimResult, error) {
|
||
characterID = strings.TrimSpace(characterID)
|
||
if characterID == "" {
|
||
return ClaimResult{}, ErrMailboxCharacterMissing
|
||
}
|
||
if o.repos.Mailbox == nil {
|
||
return ClaimResult{}, ErrMailboxRepoMissing
|
||
}
|
||
if o.repos.MailboxAssets == nil {
|
||
return ClaimResult{}, ErrMailboxAssetsUOW
|
||
}
|
||
if len(req.MailIDs) == 0 {
|
||
return ClaimResult{}, ErrMailboxInvalidRequest
|
||
}
|
||
|
||
record, found, err := o.repos.Mailbox.Load(ctx, characterID)
|
||
if err != nil {
|
||
return ClaimResult{}, err
|
||
}
|
||
if !found {
|
||
return ClaimResult{}, ErrMailboxMailNotFound
|
||
}
|
||
requiresItemResolver := false
|
||
requiresEpicBook := false
|
||
for _, claimObject := range req.MailIDs {
|
||
id, _, _ := DecodeMailboxClaimObject(claimObject)
|
||
mail, ok := record.Mails[strconv.FormatUint(uint64(id), 10)]
|
||
if !ok || mail.Claimed {
|
||
continue
|
||
}
|
||
for _, attachment := range mail.Attachments {
|
||
if attachment.ItemID <= 0 || attachment.Count <= 0 || dnfcerashop.IsVoucher(attachment.ItemID) {
|
||
continue
|
||
}
|
||
// A mailed 契约 is delivered by activation and never touches the item
|
||
// resolver.
|
||
if _, contractOK, contractErr := o.resolvePremiumContract(attachment.ItemID); contractErr != nil {
|
||
return ClaimResult{}, contractErr
|
||
} else if contractOK {
|
||
continue
|
||
}
|
||
if !dnfrevivecoin.IsReward(attachment.ItemID) {
|
||
requiresItemResolver = true
|
||
if o.itemResolver != nil {
|
||
resolution, resolveErr := o.itemResolver(uint32(attachment.ItemID))
|
||
if resolveErr != nil {
|
||
return ClaimResult{}, fmt.Errorf("%w: attachment item=%d resolve=%v", ErrMailboxAssetMismatch, attachment.ItemID, resolveErr)
|
||
}
|
||
if (resolution.EpicBookEquipmentID == 0) != (resolution.EpicBookFragmentID == 0) {
|
||
return ClaimResult{}, fmt.Errorf("%w: attachment item=%d has incomplete epic-book mapping", ErrMailboxAssetMismatch, attachment.ItemID)
|
||
}
|
||
requiresEpicBook = requiresEpicBook || resolution.EpicBookEquipmentID != 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if requiresItemResolver && o.itemResolver == nil {
|
||
return ClaimResult{}, ErrMailboxItemResolverMissing
|
||
}
|
||
result := ClaimResult{}
|
||
resultEpicBookBalances := make(map[uint32]uint32)
|
||
applyClaim := func(
|
||
accounts dnfrepo.AccountRepository,
|
||
accountInventories dnfrepo.AccountInventoryRepository,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventories dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
epicBooks dnfrepo.EpicBookRepository,
|
||
) error {
|
||
mailbox, mailboxFound, err := mailboxes.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !mailboxFound {
|
||
return ErrMailboxMailNotFound
|
||
}
|
||
now := o.clock()
|
||
selected := make([]claimCandidate, 0, len(req.MailIDs))
|
||
byMail := make(map[uint32]int, len(req.MailIDs))
|
||
for _, claimObject := range req.MailIDs {
|
||
id, attachmentIndex, singleSlot := DecodeMailboxClaimObject(claimObject)
|
||
key := strconv.FormatUint(uint64(id), 10)
|
||
mail, ok := mailbox.Mails[key]
|
||
if !ok || mail.Deleted {
|
||
return ErrMailboxMailNotFound
|
||
}
|
||
if mail.Claimed {
|
||
// The current mailbox UI keeps claimed letters visible and its
|
||
// select-all action includes them again. Ignore those completed
|
||
// rows so one old selection cannot block still-pending rewards in
|
||
// the same op95 batch.
|
||
continue
|
||
}
|
||
if mailExpired(mail, now) {
|
||
return ErrMailboxExpired
|
||
}
|
||
if mail.Gold < 0 {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
if singleSlot && attachmentIndex >= len(mail.Attachments) {
|
||
return ErrMailboxMailNotFound
|
||
}
|
||
// The client can select several slots of one letter in the same
|
||
// request; they are resolved against a single copy of it.
|
||
if position, seen := byMail[id]; seen {
|
||
if !singleSlot {
|
||
selected[position].slots = nil
|
||
selected[position].wholeLetter = true
|
||
continue
|
||
}
|
||
if !selected[position].wholeLetter {
|
||
selected[position].slots = append(selected[position].slots, attachmentIndex)
|
||
}
|
||
continue
|
||
}
|
||
candidate := claimCandidate{id: id, key: key, mail: mail, claimObject: claimObject}
|
||
if singleSlot {
|
||
candidate.slots = []int{attachmentIndex}
|
||
} else {
|
||
candidate.wholeLetter = true
|
||
}
|
||
byMail[id] = len(selected)
|
||
selected = append(selected, candidate)
|
||
}
|
||
if len(selected) == 0 {
|
||
return ErrMailboxAlreadyClaimed
|
||
}
|
||
character, characterFound, err := characters.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !characterFound {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
accountID := strings.TrimSpace(character.AccountID)
|
||
|
||
selectedHasAttachments := false
|
||
for _, candidate := range selected {
|
||
if len(candidate.mail.Attachments) > 0 {
|
||
selectedHasAttachments = true
|
||
break
|
||
}
|
||
}
|
||
inventory := dnfrepo.InventoryRecord{CharacterID: characterID}
|
||
if selectedHasAttachments {
|
||
loadedInventory, inventoryFound, loadErr := inventories.Load(ctx, characterID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !inventoryFound {
|
||
inventory = dnfrepo.InventoryRecord{CharacterID: characterID}
|
||
} else {
|
||
inventory = loadedInventory
|
||
}
|
||
if inventory.Slots == nil {
|
||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
}
|
||
accountInventory := dnfrepo.AccountInventoryRecord{AccountID: accountID}
|
||
accountInventoryLoaded := false
|
||
accountInventoryChanged := false
|
||
epicBook := dnfrepo.EpicBookRecord{AccountID: accountID}
|
||
epicBookLoaded := false
|
||
epicBookChanged := false
|
||
|
||
// Select-all requests contain the current page's mail IDs in order. Each
|
||
// letter remains an atomic asset transfer, but a full tab on one letter
|
||
// must not roll back mail that already fit into the quick slots or its
|
||
// real item tab earlier in the same request.
|
||
// 契约 attachments activate on claim, so the account premium row joins this
|
||
// transaction. It is loaded lazily: a letter without a contract never
|
||
// touches account state, and it is saved before the happy-token block so
|
||
// that block reloads the already extended row.
|
||
var premiumAccount dnfrepo.AccountRecord
|
||
premiumAccountLoaded := false
|
||
premiumAccountDirty := false
|
||
premiumExpiries := make(map[int64]int64)
|
||
activatePremium := func(resolution alignedcmd.PremiumContractResolution, count int64, at time.Time) error {
|
||
if accountID == "" || accounts == nil {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
if !premiumAccountLoaded {
|
||
loaded, found, loadErr := accounts.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !found {
|
||
return ErrMailboxAssetMismatch
|
||
}
|
||
premiumAccount = dnfrepo.CloneAccount(loaded)
|
||
premiumAccountLoaded = true
|
||
}
|
||
premium.Upsert(&premiumAccount, resolution.PremiumType, resolution.DurationSeconds, count, at)
|
||
// The client is told the new entitlement separately, so the resulting
|
||
// expiry is read back here. Several contracts of one type in the same
|
||
// request collapse onto the final value.
|
||
premiumExpiries[resolution.PremiumType] = premium.ExpireAt(premiumAccount, resolution.PremiumType)
|
||
premiumAccountDirty = true
|
||
return nil
|
||
}
|
||
|
||
accepted := make([]claimCandidate, 0, len(selected))
|
||
var totalGold int64
|
||
var totalHappyTokenCera int64
|
||
var totalTokenCera int64
|
||
inventoryChanged := false
|
||
inventoryFull := false
|
||
for _, candidate := range selected {
|
||
candidateInventory := dnfrepo.CloneInventory(inventory)
|
||
if candidateInventory.Slots == nil {
|
||
candidateInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
candidateAccountInventory := dnfrepo.CloneAccountInventory(accountInventory)
|
||
candidateEpicBook := dnfrepo.CloneEpicBook(epicBook)
|
||
candidateAccountInventoryChanged := false
|
||
candidateEpicBookChanged := false
|
||
candidateEpicBookBalances := make(map[uint32]uint32)
|
||
candidateCharacterInventoryChanged := false
|
||
candidateRefreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(candidate.mail.Attachments))
|
||
candidateItemCount := 0
|
||
var candidateHappyTokenCera int64
|
||
var candidateTokenCera int64
|
||
candidateContracts := make([]pendingPremiumContract, 0, len(candidate.mail.Attachments))
|
||
claimedSlots := make(map[int]struct{}, len(candidate.mail.Attachments))
|
||
fits := true
|
||
for _, index := range candidate.requestedSlots() {
|
||
attachment := candidate.mail.Attachments[index]
|
||
// An empty slot has already been handed over; it is skipped rather
|
||
// than treated as a corrupt letter.
|
||
if attachment.ItemID <= 0 || attachment.Count <= 0 {
|
||
continue
|
||
}
|
||
// 代币券 / 欢乐代币券:账号级货币,不占背包格。
|
||
// 它们在道具 PVF 里就是普通 `[stackable type] [material]`,
|
||
// **没有 `[action type]`** —— 客户端根本没有任何途径把它们变成钱包
|
||
// 余额,所以当道具发下去只会得到一件永远用不掉的"材料"。
|
||
if voucher := dnfcerashop.VoucherKindOf(attachment.ItemID); voucher != dnfcerashop.VoucherNone {
|
||
if voucher == dnfcerashop.VoucherTokenCera {
|
||
if candidateTokenCera > math.MaxInt32-attachment.Count {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
candidateTokenCera += attachment.Count
|
||
} else {
|
||
if candidateHappyTokenCera > math.MaxInt32-attachment.Count {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
candidateHappyTokenCera += attachment.Count
|
||
}
|
||
claimedSlots[index] = struct{}{}
|
||
continue
|
||
}
|
||
// A 契约 activates on claim: that is the delivery, and it needs no
|
||
// inventory space at all. It is staged per letter and applied only
|
||
// once the letter is accepted, so an attachment that does not fit can
|
||
// never leave the contract granted.
|
||
contract, contractOK, contractErr := o.resolvePremiumContract(attachment.ItemID)
|
||
if contractErr != nil {
|
||
return contractErr
|
||
}
|
||
if contractOK {
|
||
candidateContracts = append(candidateContracts, pendingPremiumContract{
|
||
resolution: contract,
|
||
count: attachment.Count,
|
||
})
|
||
claimedSlots[index] = struct{}{}
|
||
claimedSlots[index] = struct{}{}
|
||
candidateItemCount++
|
||
continue
|
||
}
|
||
if dnfrevivecoin.IsReward(attachment.ItemID) {
|
||
if _, grantErr := dnfrevivecoin.Grant(
|
||
&candidateInventory,
|
||
attachment.Count,
|
||
"mailbox_attachment_revive_coin_wallet",
|
||
); grantErr != nil {
|
||
return fmt.Errorf("%w: revive-coin attachment item=%d: %v", ErrMailboxAssetMismatch, attachment.ItemID, grantErr)
|
||
}
|
||
candidateCharacterInventoryChanged = true
|
||
candidateRefreshes = append(candidateRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: dnfrepo.MainInventoryListType,
|
||
SlotIndex: dnfrevivecoin.WalletSlot,
|
||
})
|
||
claimedSlots[index] = struct{}{}
|
||
candidateItemCount++
|
||
continue
|
||
}
|
||
resolution, resolveErr := o.itemResolver(uint32(attachment.ItemID))
|
||
if resolveErr != nil || !mailboxClaimSlotRangeValid(resolution) ||
|
||
(resolution.EpicBookEquipmentID == 0) != (resolution.EpicBookFragmentID == 0) {
|
||
return fmt.Errorf("%w: attachment item=%d resolve=%v", ErrMailboxAssetMismatch, attachment.ItemID, resolveErr)
|
||
}
|
||
if resolution.EpicBookEquipmentID != 0 {
|
||
if accountID == "" || epicBooks == nil || attachment.Count > math.MaxUint32 {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
if !epicBookLoaded {
|
||
loadedEpicBook, foundEpicBook, loadErr := epicBooks.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if foundEpicBook {
|
||
epicBook = dnfrepo.CloneEpicBook(loadedEpicBook)
|
||
} else {
|
||
epicBook = dnfrepo.EpicBookRecord{AccountID: accountID}
|
||
}
|
||
if epicBook.Balances == nil {
|
||
epicBook.Balances = make(map[uint32]uint32)
|
||
}
|
||
epicBookLoaded = true
|
||
candidateEpicBook = dnfrepo.CloneEpicBook(epicBook)
|
||
}
|
||
if candidateEpicBook.Balances == nil {
|
||
candidateEpicBook.Balances = make(map[uint32]uint32)
|
||
}
|
||
amount := uint32(attachment.Count)
|
||
before := candidateEpicBook.Balances[resolution.EpicBookEquipmentID]
|
||
if before > math.MaxUint32-amount {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
balance := before + amount
|
||
candidateEpicBook.Balances[resolution.EpicBookEquipmentID] = balance
|
||
candidateEpicBookChanged = true
|
||
candidateEpicBookBalances[resolution.EpicBookFragmentID] = balance
|
||
claimedSlots[index] = struct{}{}
|
||
candidateItemCount++
|
||
continue
|
||
}
|
||
accountOwned, ownerErr := mailboxAttachmentUsesAccountInventory(resolution)
|
||
if ownerErr != nil {
|
||
return fmt.Errorf("%w: attachment item=%d owner=%v", ErrMailboxAssetMismatch, attachment.ItemID, ownerErr)
|
||
}
|
||
var changedSlots []int16
|
||
var addErr error
|
||
if accountOwned {
|
||
if accountID == "" || accountInventories == nil {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
if !accountInventoryLoaded {
|
||
loadedAccountInventory, foundAccountInventory, loadErr := accountInventories.Load(ctx, accountID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if foundAccountInventory {
|
||
accountInventory = dnfrepo.CloneAccountInventory(loadedAccountInventory)
|
||
} else {
|
||
accountInventory = dnfrepo.AccountInventoryRecord{AccountID: accountID}
|
||
}
|
||
if accountInventory.Slots == nil {
|
||
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
accountInventoryLoaded = true
|
||
candidateAccountInventory = dnfrepo.CloneAccountInventory(accountInventory)
|
||
}
|
||
changedSlots, addErr = addAttachmentToAccountInventory(&candidateAccountInventory, attachment, resolution)
|
||
candidateAccountInventoryChanged = true
|
||
} else {
|
||
// One container-state read per attachment: the placement must stay
|
||
// inside the rows the character has unlocked, or the attachment lands
|
||
// in a locked bag slot the player cannot even click (live 2026-09-15).
|
||
unlockedMainSlots := o.claimUnlockedMainSlots(ctx, characterID, resolution)
|
||
changedSlots, addErr = addAttachmentToInventoryBounded(&candidateInventory, attachment, resolution, unlockedMainSlots)
|
||
candidateCharacterInventoryChanged = true
|
||
}
|
||
if errors.Is(addErr, ErrMailboxItemLocked) {
|
||
// 目标行被锁定:领取必须停下并报"锁定",不能当成背包已满
|
||
// (那会让玩家去清空背包,而问题根本不在空间上)。
|
||
return fmt.Errorf("%w: attachment item=%d list=%d", ErrMailboxItemLocked, attachment.ItemID, resolution.ListType)
|
||
}
|
||
if errors.Is(addErr, ErrMailboxInventoryFull) {
|
||
fits = false
|
||
inventoryFull = true
|
||
break
|
||
}
|
||
if addErr != nil {
|
||
return addErr
|
||
}
|
||
for _, slot := range changedSlots {
|
||
candidateRefreshes = append(candidateRefreshes, alignedcmd.ItemSlotRefresh{
|
||
ListType: resolution.ListType,
|
||
SlotIndex: slot,
|
||
})
|
||
}
|
||
claimedSlots[index] = struct{}{}
|
||
candidateItemCount++
|
||
}
|
||
if !fits {
|
||
continue
|
||
}
|
||
if len(claimedSlots) == 0 && !candidate.claimsGold() {
|
||
// Every addressed slot was already empty and there is no gold left.
|
||
continue
|
||
}
|
||
if totalGold > math.MaxInt64-candidate.mail.Gold {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
for _, contract := range candidateContracts {
|
||
if err := activatePremium(contract.resolution, contract.count, now); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if totalHappyTokenCera > math.MaxInt32-candidateHappyTokenCera {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
if totalTokenCera > math.MaxInt32-candidateTokenCera {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
totalGold += candidate.mail.Gold
|
||
totalHappyTokenCera += candidateHappyTokenCera
|
||
totalTokenCera += candidateTokenCera
|
||
inventory = candidateInventory
|
||
if candidateAccountInventoryChanged {
|
||
accountInventory = candidateAccountInventory
|
||
accountInventoryChanged = true
|
||
}
|
||
if candidateEpicBookChanged {
|
||
epicBook = candidateEpicBook
|
||
epicBookChanged = true
|
||
for fragmentID, balance := range candidateEpicBookBalances {
|
||
resultEpicBookBalances[fragmentID] = balance
|
||
}
|
||
}
|
||
inventoryChanged = inventoryChanged || candidateCharacterInventoryChanged
|
||
result.ItemCount += candidateItemCount
|
||
result.ItemSlotRefreshes = append(result.ItemSlotRefreshes, candidateRefreshes...)
|
||
candidate.claimed = claimedSlots
|
||
// The class0 remove notification retires whole letters only; a letter
|
||
// with slots still pending stays in the client's list.
|
||
if !mailboxHasAttachment(candidate.attachmentsAfterClaim()) {
|
||
result.MailIDs = append(result.MailIDs, candidate.id)
|
||
}
|
||
result.Claimed = append(result.Claimed, candidate.acknowledgedRows()...)
|
||
accepted = append(accepted, candidate)
|
||
}
|
||
if len(accepted) == 0 {
|
||
if inventoryFull {
|
||
return ErrMailboxInventoryFull
|
||
}
|
||
return ErrMailboxAlreadyClaimed
|
||
}
|
||
result.Gold = totalGold
|
||
result.HappyTokenCeraGranted = totalHappyTokenCera
|
||
result.TokenCeraGranted = totalTokenCera
|
||
|
||
if premiumAccountDirty {
|
||
premiumAccount.UpdatedAt = now
|
||
if err := accounts.Save(ctx, premiumAccount); err != nil {
|
||
return err
|
||
}
|
||
result.PremiumActivated = true
|
||
types := make([]int64, 0, len(premiumExpiries))
|
||
for premiumType := range premiumExpiries {
|
||
types = append(types, premiumType)
|
||
}
|
||
sort.Slice(types, func(i, j int) bool { return types[i] < types[j] })
|
||
result.PremiumActivations = result.PremiumActivations[:0]
|
||
for _, premiumType := range types {
|
||
expireAt := premiumExpiries[premiumType]
|
||
remaining := expireAt - now.Unix()
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
result.PremiumActivations = append(result.PremiumActivations, PremiumActivation{
|
||
PremiumType: premiumType,
|
||
ExpireAt: expireAt,
|
||
RemainingSeconds: remaining,
|
||
})
|
||
}
|
||
}
|
||
|
||
if totalGold > 0 || totalHappyTokenCera > 0 || totalTokenCera > 0 {
|
||
if totalGold > 0 {
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64)
|
||
}
|
||
if character.Stats["gold"] > math.MaxInt64-totalGold {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
// The mail attachment is consumed by the claim, so the carry
|
||
// cap discards the part that does not fit rather than failing
|
||
// the whole claim and losing the attached items with it.
|
||
after, discarded := carrygold.Credit(character, totalGold)
|
||
character.Stats["gold"] = after
|
||
result.Gold = totalGold - discarded
|
||
result.GoldDiscarded = discarded
|
||
result.GoldAfter = after
|
||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if totalHappyTokenCera > 0 || totalTokenCera > 0 {
|
||
if accountID == "" || accounts == nil {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
account, found, err := accounts.Load(ctx, accountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
account = dnfrepo.CloneAccount(account)
|
||
// 代币券与欢乐代币券都直接入账号钱包,不占背包格。
|
||
// 全有或全无:任一项溢出就整笔拒绝,绝不写半截(见
|
||
// cerashop.CreditVoucherCounts 的说明)。
|
||
if _, err := dnfcerashop.CreditVoucherCounts(&account, dnfcerashop.VoucherCounts{
|
||
TokenCera: totalTokenCera,
|
||
HappyTokenCera: totalHappyTokenCera,
|
||
}); err != nil {
|
||
return ErrMailboxInvalidRequest
|
||
}
|
||
account.UpdatedAt = now
|
||
if err := accounts.Save(ctx, account); err != nil {
|
||
return err
|
||
}
|
||
updated := dnfcerashop.WalletBalance(account)
|
||
result.Wallet = &updated
|
||
}
|
||
}
|
||
|
||
if inventoryChanged {
|
||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if accountInventoryChanged {
|
||
accountInventory.AccountID = accountID
|
||
accountInventory.UpdatedAt = now
|
||
if err := accountInventories.Save(ctx, accountInventory); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if epicBookChanged {
|
||
epicBook.AccountID = accountID
|
||
epicBook.UpdatedAt = now
|
||
if err := epicBooks.Save(ctx, epicBook); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
for _, candidate := range accepted {
|
||
mail := candidate.mail
|
||
// Attachments and gold have already been committed to the recipient's
|
||
// character/inventory in this same unit of work. Do not retain a second
|
||
// claimable projection on the mail itself.
|
||
mail.Gold = 0
|
||
mail.Read = true
|
||
// A slot claim empties exactly the slots it took and keeps every
|
||
// position, so the claim object the client still holds for a pending
|
||
// slot keeps addressing the same item. The letter closes once nothing
|
||
// is left in it.
|
||
mail.Attachments = candidate.attachmentsAfterClaim()
|
||
mail.Claimed = !mailboxHasAttachment(mail.Attachments)
|
||
if mail.Claimed {
|
||
mail.Attachments = nil
|
||
}
|
||
mailbox.Mails[candidate.key] = mail
|
||
}
|
||
mailbox.UpdatedAt = now
|
||
return dnfrepo.SaveMailboxFields(ctx, mailboxes, mailbox, dnfrepo.MailboxFieldMails)
|
||
}
|
||
var claimErr error
|
||
if requiresEpicBook {
|
||
claimAssets, ok := o.repos.MailboxAssets.(dnfrepo.MailboxClaimAssetUnitOfWork)
|
||
if !ok {
|
||
return ClaimResult{}, ErrMailboxAssetsUOW
|
||
}
|
||
claimErr = claimAssets.WithinMailboxClaimAssets(ctx, characterID, characterID, applyClaim)
|
||
} else {
|
||
claimErr = o.repos.MailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
accounts dnfrepo.AccountRepository,
|
||
accountInventories dnfrepo.AccountInventoryRepository,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventories dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
return applyClaim(accounts, accountInventories, characters, inventories, mailboxes, nil)
|
||
})
|
||
}
|
||
if claimErr != nil {
|
||
return ClaimResult{}, claimErr
|
||
}
|
||
if len(resultEpicBookBalances) > 0 {
|
||
fragmentIDs := make([]uint32, 0, len(resultEpicBookBalances))
|
||
for fragmentID := range resultEpicBookBalances {
|
||
fragmentIDs = append(fragmentIDs, fragmentID)
|
||
}
|
||
sort.Slice(fragmentIDs, func(i, j int) bool { return fragmentIDs[i] < fragmentIDs[j] })
|
||
result.EpicBookBalances = make([]alignedcmd.EpicBookBalanceProjection, 0, len(fragmentIDs))
|
||
for _, fragmentID := range fragmentIDs {
|
||
result.EpicBookBalances = append(result.EpicBookBalances, alignedcmd.EpicBookBalanceProjection{
|
||
FragmentID: fragmentID,
|
||
Balance: resultEpicBookBalances[fragmentID],
|
||
})
|
||
}
|
||
}
|
||
result.InventoryOK = true
|
||
return result, nil
|
||
}
|
||
|
||
func (o Owner) ChangeState(ctx context.Context, characterID string, req ChangeStateRequest) error {
|
||
characterID = strings.TrimSpace(characterID)
|
||
if characterID == "" {
|
||
return ErrMailboxCharacterMissing
|
||
}
|
||
if o.repos.Mailbox == nil {
|
||
return ErrMailboxRepoMissing
|
||
}
|
||
if o.repos.MailboxAssets == nil {
|
||
return ErrMailboxAssetsUOW
|
||
}
|
||
if req.Status != 0 && req.Status != 2 && req.Status != 3 {
|
||
return fmt.Errorf("%w: %d", ErrMailboxStateUnsupported, req.Status)
|
||
}
|
||
return o.repos.MailboxAssets.WithinMailboxAssets(ctx, characterID, characterID, func(
|
||
_ dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
_ dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
record, found, err := mailboxes.Load(ctx, characterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found || len(record.Mails) == 0 {
|
||
return ErrMailboxMailNotFound
|
||
}
|
||
now := o.clock()
|
||
savedCount := 0
|
||
for _, candidate := range record.Mails {
|
||
if !candidate.Deleted && !mailExpired(candidate, now) && mailboxMailSaved(candidate) {
|
||
savedCount++
|
||
}
|
||
}
|
||
for _, id := range req.MailIDs {
|
||
key := strconv.FormatUint(uint64(id), 10)
|
||
mail, ok := record.Mails[key]
|
||
if !ok || mail.Deleted || mailExpired(mail, now) {
|
||
return ErrMailboxMailNotFound
|
||
}
|
||
switch req.Status {
|
||
case 0:
|
||
if !mail.Claimed && (mail.Gold > 0 || len(mail.Attachments) > 0) {
|
||
return ErrMailboxDeleteClaimable
|
||
}
|
||
mail.Deleted = true
|
||
case 2:
|
||
mail.Read = true
|
||
case 3:
|
||
if !mailboxMailSaved(mail) && savedCount >= mailboxSavedPageSize {
|
||
return ErrMailboxSavedFull
|
||
}
|
||
if mail.Metadata == nil {
|
||
mail.Metadata = make(map[string]string)
|
||
}
|
||
if !mailboxMailSaved(mail) {
|
||
savedCount++
|
||
}
|
||
mail.Metadata["mailbox_saved"] = "true"
|
||
mail.Read = true
|
||
}
|
||
record.Mails[key] = mail
|
||
}
|
||
record.UpdatedAt = now
|
||
return dnfrepo.SaveMailboxFields(ctx, mailboxes, record, dnfrepo.MailboxFieldMails)
|
||
})
|
||
}
|
||
|
||
func (o Owner) loadOrCreateMailbox(ctx context.Context, characterID string) (dnfrepo.MailboxRecord, bool, error) {
|
||
record, found, err := o.repos.Mailbox.Load(ctx, characterID)
|
||
if err != nil {
|
||
return dnfrepo.MailboxRecord{}, false, err
|
||
}
|
||
if found {
|
||
if record.Mails == nil {
|
||
record.Mails = make(map[string]dnfrepo.MailRecord)
|
||
}
|
||
return record, true, nil
|
||
}
|
||
record = dnfrepo.MailboxRecord{
|
||
CharacterID: characterID,
|
||
Mails: make(map[string]dnfrepo.MailRecord),
|
||
UpdatedAt: o.clock(),
|
||
}
|
||
if err := o.repos.Mailbox.Save(ctx, record); err != nil {
|
||
return dnfrepo.MailboxRecord{}, false, err
|
||
}
|
||
return record, false, nil
|
||
}
|
||
|
||
func clearClaimedMailboxAssets(record *dnfrepo.MailboxRecord) bool {
|
||
if record == nil || len(record.Mails) == 0 {
|
||
return false
|
||
}
|
||
changed := false
|
||
for key, mail := range record.Mails {
|
||
if !mail.Claimed || (mail.Gold == 0 && len(mail.Attachments) == 0) {
|
||
continue
|
||
}
|
||
mail.Gold = 0
|
||
mail.Attachments = nil
|
||
record.Mails[key] = mail
|
||
changed = true
|
||
}
|
||
return changed
|
||
}
|
||
|
||
// claimUnlockedMainSlots 返回这次领取在主背包里"最后一个已解锁格 + 1",也就是
|
||
// mailboxClaimStackSlots 使用的排他上界:候选槽位必须 < 返回值。
|
||
//
|
||
// 口径与 inventory.moveDestinationUnlockedSlotRange(跨容器落点修正)逐字一致:
|
||
// 客户端主背包每个标准分类是 7 行 × 8 格 = 56 格,前 32 格随角色解锁,之后按
|
||
// CharacterContainerState.MainSlotCount(0/8/16/24,即已购买的行数)逐行开放;
|
||
// 因此已解锁区间是 [resolution.SlotStart, resolution.SlotStart + 32 - 1 + MainSlotCount],
|
||
// 上界为 SlotStart + 32 + MainSlotCount。dnfbridge 侧同一口径见
|
||
// currentMainInventoryUnlockedEnd。
|
||
//
|
||
// fail-open:读不到容器状态、拿不到可证明的 PVF 范围起点、或不是主背包标准 56 格
|
||
// 分类(快捷栏 3..8、固定材料 345..359、宠物 7 / 装扮 1 / 守护石 38 页都不按行解锁)
|
||
// 时返回 0,即保持旧的无界落点。宁可"少修一次",也不能因为算错窗口挡掉全部领取
|
||
// ——那比原 bug 更糟。
|
||
func (o Owner) claimUnlockedMainSlots(
|
||
ctx context.Context,
|
||
characterID string,
|
||
resolution alignedcmd.MailboxItemResolution,
|
||
) int16 {
|
||
if o.repos.Settings == nil || strings.TrimSpace(characterID) == "" ||
|
||
resolution.ListType != dnfrepo.MainInventoryListType {
|
||
return 0
|
||
}
|
||
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, o.repos.Settings, characterID)
|
||
if err != nil || !found {
|
||
return 0
|
||
}
|
||
// PVF 范围的起点就是"这一页第 0 格";起点都拿不到就不可能算出窗口。
|
||
if resolution.SlotStart < 0 || resolution.SlotStart >= mailboxMainLastItemSlot {
|
||
return 0
|
||
}
|
||
// 只有标准分类(65..120 材料 / 121..176 消耗品 / 177..232 任务 …)按行走,
|
||
// 其它区段一律不设界。
|
||
if resolution.SlotEnd-resolution.SlotStart+1 != mailboxMainInventoryCategorySlotCount {
|
||
return 0
|
||
}
|
||
if state.MainSlotCount > mailboxMainInventoryMaxExpansionSlots {
|
||
return 0
|
||
}
|
||
return resolution.SlotStart + mailboxMainInventoryBaseSlotCount + int16(state.MainSlotCount)
|
||
}
|
||
|
||
func (o Owner) clock() time.Time {
|
||
if o.now == nil {
|
||
return time.Now().UTC()
|
||
}
|
||
return o.now().UTC()
|
||
}
|
||
|
||
func addAttachmentToInventory(inventory *dnfrepo.InventoryRecord, attachment dnfrepo.MailAttachment, resolution alignedcmd.MailboxItemResolution) ([]int16, error) {
|
||
return addAttachmentToInventoryBounded(inventory, attachment, resolution, 0)
|
||
}
|
||
|
||
// addAttachmentToInventoryBounded is the same placement with the character's
|
||
// unlocked main-inventory rows taken into account.
|
||
//
|
||
// A claim used to pick purely from the PVF slot range, so an attachment could
|
||
// land in a row the character has not bought yet - live 2026-09-15: mail
|
||
// attachments were extracted into locked bag slots, where the player cannot even
|
||
// click them. unlockedMainSlots here is an **exclusive upper bound** (the last
|
||
// unlocked slot + 1), not a count: see claimUnlockedMainSlots. Zero or less means
|
||
// "unknown", which keeps the previous unbounded behaviour for the account-cargo
|
||
// path and for item pages that are not row-expanded.
|
||
func addAttachmentToInventoryBounded(inventory *dnfrepo.InventoryRecord, attachment dnfrepo.MailAttachment, resolution alignedcmd.MailboxItemResolution, unlockedMainSlots int16) ([]int16, error) {
|
||
accountOwned, ownerErr := mailboxAttachmentUsesAccountInventory(resolution)
|
||
if inventory == nil || attachment.ItemID <= 0 || attachment.Count <= 0 ||
|
||
!mailboxClaimSlotRangeValid(resolution) || ownerErr != nil || accountOwned {
|
||
return nil, ErrMailboxInvalidRequest
|
||
}
|
||
return addAttachmentToInventoryRange(inventory, attachment, resolution, unlockedMainSlots)
|
||
}
|
||
|
||
// addAttachmentToAccountInventory reuses the stack/slot allocator while
|
||
// keeping account-shared list-0 slots under their account-scoped durable
|
||
// aggregate. The wire slot numbers deliberately stay unchanged.
|
||
func addAttachmentToAccountInventory(account *dnfrepo.AccountInventoryRecord, attachment dnfrepo.MailAttachment, resolution alignedcmd.MailboxItemResolution) ([]int16, error) {
|
||
accountOwned, ownerErr := mailboxAttachmentUsesAccountInventory(resolution)
|
||
if account == nil || !accountOwned || ownerErr != nil {
|
||
return nil, ErrMailboxInvalidRequest
|
||
}
|
||
inventory := dnfrepo.InventoryRecord{Slots: account.Slots}
|
||
// The account cargo is fully open, so no unlocked-slot bound applies.
|
||
changedSlots, err := addAttachmentToInventoryRange(&inventory, attachment, resolution, 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
account.Slots = inventory.Slots
|
||
return changedSlots, nil
|
||
}
|
||
|
||
func addAttachmentToInventoryRange(inventory *dnfrepo.InventoryRecord, attachment dnfrepo.MailAttachment, resolution alignedcmd.MailboxItemResolution, unlockedMainSlots int16) ([]int16, error) {
|
||
if inventory == nil || attachment.ItemID <= 0 || attachment.Count <= 0 || !mailboxClaimSlotRangeValid(resolution) {
|
||
return nil, ErrMailboxInvalidRequest
|
||
}
|
||
if inventory.Slots == nil {
|
||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||
}
|
||
// 附件并进一行**锁定**的同类堆必须被拒绝:装备锁的语义是不可交易/强化/分解/丢弃,
|
||
// 把锁定件数量悄悄加一等于绕过锁。同一容器里同一道具 id 只有一行
|
||
// (CanOpenContainerItemRow 的口径),所以扫一遍候选槽位上的那一行就够。
|
||
for _, slot := range mailboxClaimStackSlots(resolution, unlockedMainSlots) {
|
||
stack, occupied := inventory.Slots[mailboxInventorySlotKey(resolution.ListType, slot)]
|
||
if !occupied || stack.ItemID != attachment.ItemID {
|
||
continue
|
||
}
|
||
if mailboxStackLocked(stack) {
|
||
return nil, fmt.Errorf("%w: list=%d slot=%d item=%d", ErrMailboxItemLocked, resolution.ListType, slot, attachment.ItemID)
|
||
}
|
||
}
|
||
remaining := attachment.Count
|
||
stack := mailboxAttachmentStack(attachment, remaining, resolution)
|
||
// PVF [stack limit] 是这个容器里该道具的总量上限,不是一个格子的上限:额度按容器
|
||
// 里同 id 总量算,而且容器里已经有该道具的行时只能并进那一行(CanOpenContainerItemRow)。
|
||
// 只有声明了 [stack limit] 的可叠加道具受这条约束;装备(每件一行)和没写
|
||
// [stack limit] 的道具沿用旧行为。
|
||
capped := mailboxCarryLimitCapped(resolution)
|
||
newRowLimit := int64(0)
|
||
space := int64(math.MaxInt32)
|
||
if capped {
|
||
newRowLimit = resolution.StackLimit
|
||
space = dnfrepo.ContainerItemSpace(inventory.Slots, resolution.ListType, attachment.ItemID, resolution.StackLimit)
|
||
}
|
||
changedSlots := make([]int16, 0, 1)
|
||
for _, slot := range mailboxClaimStackSlots(resolution, unlockedMainSlots) {
|
||
if remaining <= 0 || space <= 0 {
|
||
break
|
||
}
|
||
key := mailboxInventorySlotKey(resolution.ListType, slot)
|
||
existing, occupied := inventory.Slots[key]
|
||
if !occupied || !canMailStack(existing, stack, resolution) || existing.Count < 0 {
|
||
continue
|
||
}
|
||
capacity := remaining
|
||
stackLimit := mailboxEffectiveStackLimit(resolution)
|
||
if stackLimit > 0 {
|
||
if existing.Count >= stackLimit {
|
||
continue
|
||
}
|
||
capacity = stackLimit - existing.Count
|
||
if capacity > remaining {
|
||
capacity = remaining
|
||
}
|
||
}
|
||
if capacity > space {
|
||
capacity = space
|
||
}
|
||
if capacity <= 0 || existing.Count > math.MaxInt64-capacity {
|
||
return nil, ErrMailboxInvalidRequest
|
||
}
|
||
existing = normalizeMailboxStackClassification(existing, resolution)
|
||
existing.Count += capacity
|
||
updateMailboxStackRawAmount(&existing, resolution)
|
||
inventory.Slots[key] = existing
|
||
changedSlots = append(changedSlots, slot)
|
||
remaining -= capacity
|
||
space -= capacity
|
||
}
|
||
for _, slot := range mailboxClaimStackSlots(resolution, unlockedMainSlots) {
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
key := mailboxInventorySlotKey(resolution.ListType, slot)
|
||
if _, occupied := inventory.Slots[key]; occupied {
|
||
continue
|
||
}
|
||
// 容器里已经有该道具的行时不能再开第二行:额度用尽/已有行都停在这里,
|
||
// 余量交给调用方的既有处理(整封信保留,返回 ErrMailboxInventoryFull)。
|
||
if !dnfrepo.CanOpenContainerItemRow(inventory.Slots, resolution.ListType, attachment.ItemID, newRowLimit) {
|
||
break
|
||
}
|
||
count := remaining
|
||
if stackLimit := mailboxEffectiveStackLimit(resolution); stackLimit > 0 && count > stackLimit {
|
||
count = stackLimit
|
||
}
|
||
if capped && count > space {
|
||
count = space
|
||
}
|
||
if count <= 0 {
|
||
break
|
||
}
|
||
inventory.Slots[key] = mailboxAttachmentStack(attachment, count, resolution)
|
||
changedSlots = append(changedSlots, slot)
|
||
remaining -= count
|
||
space -= count
|
||
}
|
||
if remaining > 0 {
|
||
return nil, ErrMailboxInventoryFull
|
||
}
|
||
return changedSlots, nil
|
||
}
|
||
|
||
// mailboxCarryLimitCapped 报告该附件是否受"一个容器里同一道具 id 的总量不得超过
|
||
// PVF [stack limit]"约束。装备(每件一行)和 PVF 没写 [stack limit] 的道具不受限。
|
||
func mailboxCarryLimitCapped(resolution alignedcmd.MailboxItemResolution) bool {
|
||
return strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") && resolution.StackLimit > 0
|
||
}
|
||
|
||
func mailboxClaimSlotRangeValid(resolution alignedcmd.MailboxItemResolution) bool {
|
||
if resolution.SlotEnd < resolution.SlotStart {
|
||
return false
|
||
}
|
||
switch resolution.ListType {
|
||
case dnfrepo.MainInventoryListType:
|
||
return resolution.SlotStart >= mailboxMainFirstItemSlot && resolution.SlotEnd <= mailboxMainLastItemSlot
|
||
case 1: // avatar inventory
|
||
return resolution.SlotStart >= 0 && resolution.SlotEnd <= 500
|
||
case 7: // creature body/equipment/consumable inventory
|
||
return resolution.SlotStart >= 0 && resolution.SlotEnd <= 238
|
||
case 38: // guild medal and guardian-gem inventory
|
||
return resolution.SlotStart >= 0 && resolution.SlotEnd <= 97
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func mailboxAttachmentUsesAccountInventory(resolution alignedcmd.MailboxItemResolution) (bool, error) {
|
||
if !mailboxClaimSlotRangeValid(resolution) {
|
||
return false, ErrMailboxInvalidRequest
|
||
}
|
||
if resolution.ListType == dnfrepo.MainInventoryListType &&
|
||
resolution.SlotStart >= dnfrepo.CrystalWarehouseFirstSlot && resolution.SlotEnd <= dnfrepo.SoulWarehouseLastSlot {
|
||
return true, nil
|
||
}
|
||
if resolution.ListType == dnfrepo.MainInventoryListType &&
|
||
resolution.SlotStart <= dnfrepo.SoulWarehouseLastSlot && resolution.SlotEnd >= dnfrepo.CrystalWarehouseFirstSlot {
|
||
// One PVF definition may never span a character-owned tab and an
|
||
// account-owned warehouse: that would make a single item have two
|
||
// durable owners.
|
||
return false, ErrMailboxInvalidRequest
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// mailboxClaimStackSlots lists the candidate slots one attachment may occupy.
|
||
// unlockedMainSlots is the exclusive upper bound computed by
|
||
// claimUnlockedMainSlots (the last unlocked slot + 1); the PVF range alone would
|
||
// also offer locked rows, which is how a claim used to fill a bag slot the player
|
||
// cannot reach. Zero or less keeps the unbounded behaviour. Quick slots 3..8 are
|
||
// never row-expanded, so the bound deliberately does not apply to them.
|
||
func mailboxClaimStackSlots(resolution alignedcmd.MailboxItemResolution, unlockedMainSlots int16) []int16 {
|
||
slots := make([]int16, 0, 6+int(resolution.SlotEnd-resolution.SlotStart+1))
|
||
accountOwned, _ := mailboxAttachmentUsesAccountInventory(resolution)
|
||
prependQuickSlots := !accountOwned && resolution.ListType == dnfrepo.MainInventoryListType && resolution.PreferQuickSlots && resolution.StackLimit != 1
|
||
mainBounded := !accountOwned && resolution.ListType == dnfrepo.MainInventoryListType && unlockedMainSlots > 0
|
||
if prependQuickSlots {
|
||
for slot := int16(3); slot <= 8; slot++ {
|
||
slots = append(slots, slot)
|
||
}
|
||
}
|
||
for slot := resolution.SlotStart; slot <= resolution.SlotEnd; slot++ {
|
||
if prependQuickSlots && slot >= 3 && slot <= 8 {
|
||
continue
|
||
}
|
||
if mainBounded && slot >= unlockedMainSlots {
|
||
continue
|
||
}
|
||
slots = append(slots, slot)
|
||
}
|
||
return slots
|
||
}
|
||
|
||
func mailboxAttachmentStack(attachment dnfrepo.MailAttachment, count int64, resolution alignedcmd.MailboxItemResolution) dnfrepo.ItemStack {
|
||
extra := cloneStringMap(attachment.Extra)
|
||
// These three keys are mailbox-envelope framing evidence captured when a
|
||
// player sends an item. They are not durable item state, so retaining them
|
||
// would prevent a returned stackable from merging with the same item in the
|
||
// recipient's real inventory page.
|
||
delete(extra, "mailbox_pvf_kind")
|
||
delete(extra, "mailbox_equipment_type")
|
||
delete(extra, "mailbox_pvf_path")
|
||
if extra == nil {
|
||
extra = make(map[string]string)
|
||
}
|
||
// A mail envelope may be generated without the durable item metadata used
|
||
// by the current op13/op14 projection and by item-specific handlers. Never
|
||
// trust or preserve stale envelope values here: the resolver has already
|
||
// looked up the exact template in the active runtime PVF.
|
||
if kind := strings.ToLower(strings.TrimSpace(resolution.Kind)); kind != "" {
|
||
extra["item_kind"] = kind
|
||
}
|
||
if pvfPath := strings.TrimSpace(resolution.PVFPath); pvfPath != "" {
|
||
extra["pvf_path"] = pvfPath
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(resolution.Kind)) {
|
||
case "stackable":
|
||
delete(extra, "equipment_type")
|
||
if stackableType := strings.TrimSpace(resolution.StackableType); stackableType != "" {
|
||
extra["stackable_type"] = stackableType
|
||
} else {
|
||
delete(extra, "stackable_type")
|
||
}
|
||
if resolution.StackLimit > 0 {
|
||
extra["stack_limit"] = strconv.FormatInt(resolution.StackLimit, 10)
|
||
} else {
|
||
delete(extra, "stack_limit")
|
||
}
|
||
case "equipment":
|
||
delete(extra, "stackable_type")
|
||
delete(extra, "stack_limit")
|
||
if equipmentType := strings.TrimSpace(resolution.EquipmentType); equipmentType != "" {
|
||
extra["equipment_type"] = equipmentType
|
||
} else {
|
||
delete(extra, "equipment_type")
|
||
}
|
||
if resolution.Durability > 0 {
|
||
extra["max_durability"] = strconv.FormatUint(uint64(resolution.Durability), 10)
|
||
if strings.TrimSpace(extra["durability"]) == "" {
|
||
currentDurability := resolution.Durability
|
||
if len(attachment.RawEntry) == mailboxItemRawSize {
|
||
if rawDurability := binary.LittleEndian.Uint16(attachment.RawEntry[0x0b:0x0d]); rawDurability > 0 {
|
||
currentDurability = rawDurability
|
||
}
|
||
}
|
||
extra["durability"] = strconv.FormatUint(uint64(currentDurability), 10)
|
||
}
|
||
}
|
||
}
|
||
stack := normalizeMailboxStackClassification(dnfrepo.ItemStack{
|
||
ItemID: attachment.ItemID,
|
||
Count: count,
|
||
Bind: attachment.Bind,
|
||
ExpireAt: attachment.ExpireAt,
|
||
RawEntry: append([]byte(nil), attachment.RawEntry...),
|
||
Extra: extra,
|
||
}, resolution)
|
||
updateMailboxStackRawAmount(&stack, resolution)
|
||
return stack
|
||
}
|
||
|
||
func mailboxInventorySlotKey(listType byte, slot int16) string {
|
||
return fmt.Sprintf("%d:%d", listType, slot)
|
||
}
|
||
|
||
func canMailStack(left, right dnfrepo.ItemStack, resolution alignedcmd.MailboxItemResolution) bool {
|
||
return strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") &&
|
||
resolution.StackLimit != 1 &&
|
||
left.ItemID == right.ItemID &&
|
||
left.Bind == right.Bind &&
|
||
left.ExpireAt.Equal(right.ExpireAt)
|
||
}
|
||
|
||
func mailboxEffectiveStackLimit(resolution alignedcmd.MailboxItemResolution) int64 {
|
||
if !strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") {
|
||
return 1
|
||
}
|
||
if resolution.StackLimit > 0 {
|
||
return resolution.StackLimit
|
||
}
|
||
return math.MaxInt32
|
||
}
|
||
|
||
func normalizeMailboxStackClassification(stack dnfrepo.ItemStack, resolution alignedcmd.MailboxItemResolution) dnfrepo.ItemStack {
|
||
extra := cloneStringMap(stack.Extra)
|
||
if extra == nil {
|
||
extra = make(map[string]string, 4)
|
||
}
|
||
kind := strings.ToLower(strings.TrimSpace(resolution.Kind))
|
||
if kind != "" {
|
||
extra["item_kind"] = kind
|
||
}
|
||
if pvfPath := strings.TrimSpace(strings.ReplaceAll(resolution.PVFPath, "\\", "/")); pvfPath != "" {
|
||
extra["pvf_path"] = pvfPath
|
||
}
|
||
if kind == "stackable" {
|
||
delete(extra, "equipment_type")
|
||
if stackableType := strings.TrimSpace(resolution.StackableType); stackableType != "" {
|
||
extra["stackable_type"] = stackableType
|
||
} else {
|
||
delete(extra, "stackable_type")
|
||
}
|
||
if resolution.StackLimit > 0 {
|
||
extra["stack_limit"] = strconv.FormatInt(resolution.StackLimit, 10)
|
||
} else {
|
||
delete(extra, "stack_limit")
|
||
}
|
||
}
|
||
stack.Extra = extra
|
||
return stack
|
||
}
|
||
|
||
func updateMailboxStackRawAmount(stack *dnfrepo.ItemStack, resolution alignedcmd.MailboxItemResolution) {
|
||
// raw+0x06 is the quantity only for stackables. For equipment the same
|
||
// dword is the durable instance/quality seed; replacing it with Count makes
|
||
// the current client decode the following reinforce/amplification fields as
|
||
// a malformed multi-item row after a mailbox claim.
|
||
if !strings.EqualFold(strings.TrimSpace(resolution.Kind), "stackable") ||
|
||
stack == nil || stack.Count <= 0 || stack.Count > math.MaxUint32 || len(stack.RawEntry) != mailboxItemRawSize {
|
||
return
|
||
}
|
||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||
binary.LittleEndian.PutUint32(stack.RawEntry[0x06:0x0a], uint32(stack.Count))
|
||
}
|
||
|
||
func nextMailID(mails map[string]dnfrepo.MailRecord) (string, error) {
|
||
var max uint64
|
||
for key := range mails {
|
||
value, err := strconv.ParseUint(strings.TrimSpace(key), 10, 32)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if value > max {
|
||
max = value
|
||
}
|
||
}
|
||
if max >= math.MaxUint32 {
|
||
return "", ErrMailboxInvalidRequest
|
||
}
|
||
return strconv.FormatUint(max+1, 10), nil
|
||
}
|
||
|
||
func mailboxSendSource(req SendRequest) string {
|
||
if len(req.Attachments) > 1 {
|
||
return "op315"
|
||
}
|
||
return "op94"
|
||
}
|
||
|
||
func currentMailboxPostage(gold int64, attachmentCount int, attachmentValue int64) (int64, error) {
|
||
if gold < 0 || attachmentCount < 0 || attachmentValue < 0 {
|
||
return 0, ErrMailboxInvalidRequest
|
||
}
|
||
if attachmentCount > (math.MaxInt64-100)/1000 {
|
||
return 0, ErrMailboxInvalidRequest
|
||
}
|
||
postage := int64(100 + attachmentCount*1000)
|
||
for _, surcharge := range []int64{gold / 20, attachmentValue / 20} {
|
||
if postage > math.MaxInt64-surcharge {
|
||
return 0, ErrMailboxInvalidRequest
|
||
}
|
||
postage += surcharge
|
||
}
|
||
return postage, nil
|
||
}
|
||
|
||
func mailExpired(mail dnfrepo.MailRecord, now time.Time) bool {
|
||
if mailboxMailSaved(mail) || mailboxMailUnlimited(mail) || now.IsZero() {
|
||
return false
|
||
}
|
||
expiresAt := mail.ExpireAt
|
||
if expiresAt.IsZero() && !mail.CreatedAt.IsZero() {
|
||
expiresAt = mail.CreatedAt.Add(mailboxNormalLifetime)
|
||
}
|
||
return !expiresAt.IsZero() && now.After(expiresAt)
|
||
}
|
||
|
||
func mailboxMailSaved(mail dnfrepo.MailRecord) bool {
|
||
return strings.EqualFold(strings.TrimSpace(mail.Metadata["mailbox_saved"]), "true")
|
||
}
|
||
|
||
func mailboxMailUnlimited(mail dnfrepo.MailRecord) bool {
|
||
return strings.EqualFold(strings.TrimSpace(mail.Metadata["system_mail"]), "true") &&
|
||
strings.EqualFold(strings.TrimSpace(mail.Metadata["unlimited"]), "true")
|
||
}
|
||
|
||
func sortMailboxMails(mails []dnfrepo.MailRecord) {
|
||
sort.SliceStable(mails, func(i, j int) bool {
|
||
left, right := mails[i], mails[j]
|
||
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||
return left.CreatedAt.Before(right.CreatedAt)
|
||
}
|
||
leftID, leftErr := strconv.ParseUint(left.MailID, 10, 32)
|
||
rightID, rightErr := strconv.ParseUint(right.MailID, 10, 32)
|
||
if leftErr == nil && rightErr == nil {
|
||
return leftID < rightID
|
||
}
|
||
return left.MailID < right.MailID
|
||
})
|
||
}
|
||
|
||
func cloneMailboxMail(in dnfrepo.MailRecord) dnfrepo.MailRecord {
|
||
out := in
|
||
out.Attachments = append([]dnfrepo.MailAttachment(nil), in.Attachments...)
|
||
for index := range out.Attachments {
|
||
out.Attachments[index].RawEntry = append([]byte(nil), in.Attachments[index].RawEntry...)
|
||
out.Attachments[index].Extra = cloneStringMap(in.Attachments[index].Extra)
|
||
}
|
||
out.Metadata = cloneStringMap(in.Metadata)
|
||
return out
|
||
}
|
||
|
||
func cloneStringMap(in map[string]string) map[string]string {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(in))
|
||
for key, value := range in {
|
||
out[key] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func characterIDFromRequest(reqCharacterID uint16) (string, error) {
|
||
if reqCharacterID == 0 {
|
||
return "", ErrMailboxCharacterMissing
|
||
}
|
||
return strconv.FormatUint(uint64(reqCharacterID), 10), nil
|
||
}
|
||
|
||
// claimCandidate is one letter an op95 request touches. A request addresses
|
||
// either the whole letter - which is what a gold-only row and every older client
|
||
// build sends - or a set of attachment slots.
|
||
type claimCandidate struct {
|
||
id uint32
|
||
claimObject uint32
|
||
key string
|
||
mail dnfrepo.MailRecord
|
||
slots []int
|
||
wholeLetter bool
|
||
claimed map[int]struct{}
|
||
}
|
||
|
||
// requestedSlots lists the attachment slots this claim addresses, in letter
|
||
// order.
|
||
func (c claimCandidate) requestedSlots() []int {
|
||
if c.wholeLetter || len(c.slots) == 0 {
|
||
out := make([]int, 0, len(c.mail.Attachments))
|
||
for index := range c.mail.Attachments {
|
||
out = append(out, index)
|
||
}
|
||
return out
|
||
}
|
||
out := append([]int(nil), c.slots...)
|
||
sort.Ints(out)
|
||
return out
|
||
}
|
||
|
||
// claimsGold reports whether this claim also hands over the letter's gold. A
|
||
// single-slot claim leaves the gold with the letter so the row stays claimable.
|
||
func (c claimCandidate) claimsGold() bool {
|
||
return c.wholeLetter && c.mail.Gold > 0
|
||
}
|
||
|
||
// acknowledgedRows lists what op95 must report back for this letter: one row per
|
||
// slot actually handed over, each carrying the same claim object the summary row
|
||
// gave that slot, so the client can retire exactly those slots from an open
|
||
// letter. A letter that only gave up its gold is reported through the claim
|
||
// object the client sent, which is the letter id for such a row.
|
||
func (c claimCandidate) acknowledgedRows() []ClaimedAttachment {
|
||
rows := make([]ClaimedAttachment, 0, len(c.claimed))
|
||
seen := make(map[uint32]struct{}, len(c.claimed))
|
||
for _, index := range c.requestedSlots() {
|
||
if _, ok := c.claimed[index]; !ok {
|
||
continue
|
||
}
|
||
// The same addressing the summary row used, including its fallback for a
|
||
// slot that cannot be encoded: several such slots share the letter id, so
|
||
// they are reported once.
|
||
claimObject := mailboxSlotClaimObject(c.id, index)
|
||
if _, duplicate := seen[claimObject]; duplicate {
|
||
continue
|
||
}
|
||
seen[claimObject] = struct{}{}
|
||
rows = append(rows, ClaimedAttachment{LetterID: c.id, ClaimObject: claimObject})
|
||
}
|
||
if len(rows) == 0 {
|
||
rows = append(rows, ClaimedAttachment{LetterID: c.id, ClaimObject: c.claimObject})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// attachmentsAfterClaim empties the claimed slots while keeping every position,
|
||
// so a claim object the client still holds for a pending slot keeps addressing
|
||
// the same item.
|
||
func (c claimCandidate) attachmentsAfterClaim() []dnfrepo.MailAttachment {
|
||
out := make([]dnfrepo.MailAttachment, len(c.mail.Attachments))
|
||
copy(out, c.mail.Attachments)
|
||
for index := range c.claimed {
|
||
if index >= 0 && index < len(out) {
|
||
out[index] = dnfrepo.MailAttachment{}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// mailboxHasAttachment reports whether any slot still holds an item.
|
||
func mailboxHasAttachment(attachments []dnfrepo.MailAttachment) bool {
|
||
for _, attachment := range attachments {
|
||
if attachment.ItemID > 0 && attachment.Count > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// mailboxClaimErrorCode maps a rejected op95 claim onto the codes the client
|
||
// actually renders. 86JP MailboxHandler.ToClientErrorCode keeps a separate table
|
||
// for ackType 0x005F: 4 means the attachment could not be inserted, after which
|
||
// the client runs its own inventory check and draws the no-space or carry-limit
|
||
// message; 10 is the gold carry limit, 19 a trade restriction and 21 a mail that
|
||
// no longer exists. The send-side table (mailboxErrorCode) is a different one and
|
||
// stays untouched - reusing it here made a full-tab rejection arrive as code 6,
|
||
// which is outside the claim table, so the client drew nothing and the button
|
||
// looked dead.
|
||
func mailboxClaimErrorCode(err error) byte {
|
||
switch {
|
||
case err == nil:
|
||
return 0
|
||
case errors.Is(err, ErrMailboxInventoryFull), errors.Is(err, ErrMailboxAssetMismatch),
|
||
errors.Is(err, ErrMailboxItemLocked):
|
||
// 码 4 在 op95 的失败表里是 dstr 2057「物品栏空间不足」——这正是背包放不下时
|
||
// 该显示的那句。锁定附件目前共用这一格:op95 的表只有
|
||
// {4,10,19,21,60,119,219,231} 有文案,其余(含 214)默认分支是 `pop ebp; ret`,
|
||
// 也就是说锁定在 op95 上**没有**能渲染"物品已锁定"的码。
|
||
// 现场证据(2026-09-15):领取被拒时服务端回 0004,客户端什么都没画 ——
|
||
// 玩家看到的仍是"没反应",但安全性不受影响(附件留在信里)。
|
||
return 4
|
||
case errors.Is(err, ErrMailboxGoldInsufficient):
|
||
return 10
|
||
case errors.Is(err, ErrMailboxMailNotFound), errors.Is(err, ErrMailboxAlreadyClaimed), errors.Is(err, ErrMailboxExpired):
|
||
return 21
|
||
default:
|
||
return mailboxErrorCode(err)
|
||
}
|
||
}
|
||
|
||
func mailboxErrorCode(err error) byte {
|
||
switch {
|
||
case err == nil:
|
||
return 0
|
||
case errors.Is(err, ErrMailboxRecipientMissing):
|
||
return 2
|
||
case errors.Is(err, ErrMailboxMailNotFound):
|
||
return 3
|
||
case errors.Is(err, ErrMailboxAlreadyClaimed):
|
||
return 4
|
||
case errors.Is(err, ErrMailboxExpired):
|
||
return 5
|
||
case errors.Is(err, ErrMailboxInventoryFull):
|
||
return 6
|
||
case errors.Is(err, ErrMailboxGoldInsufficient), errors.Is(err, ErrMailboxAssetMismatch):
|
||
return 7
|
||
case errors.Is(err, ErrMailboxSelfRecipient):
|
||
return 7
|
||
case errors.Is(err, ErrMailboxSavedFull):
|
||
return 22
|
||
case errors.Is(err, ErrMailboxItemResolverMissing), errors.Is(err, ErrMailboxSpecialUnsupported):
|
||
return 7
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
func isMissingRepositoryError(err error) bool {
|
||
return errors.Is(err, ErrMailboxRepoMissing) ||
|
||
errors.Is(err, ErrMailboxAssetsUOW) ||
|
||
errors.Is(err, dnfrepo.ErrMailboxAssetTransactionUnavailable)
|
||
}
|