Files
90CNServer/server/internal/modules/dnf/mail/owner.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1886 lines
70 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}