DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+739
View File
@@ -0,0 +1,739 @@
package mail
import (
"encoding/binary"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
MailboxListNotificationMessageID uint16 = 0x0061
mailboxListNotificationMessageID = MailboxListNotificationMessageID
mailboxRemoveNotificationMessageID uint16 = 0x0062
MailboxAlarmNotificationMessageID uint16 = 0x0063
// Current NoPack.exe registers class0/op718 beside mailbox op97/op98/op99
// notifications as the response that fills IDC_TREE_MYCHARACTER after
// class1/op789 requests a server node.
mailboxRecipientListMessageID uint16 = 718
mailboxRecipientListNameMaxBytes = 29
mailboxItemRawSize = 0x77
// The two avatar extension blocks the current client reads after the fixed
// item row target a 30-byte jewel socket and a single colour DWORD, the same
// pair the list-1 item-list rows already carry.
mailboxAvatarSocketBytes = 30
mailboxAvatarColorBytes = 4
)
// BuildListNotification creates the complete clear-first class0/op97 mailbox
// snapshot used by an open mailbox and by trusted live GM deliveries.
func BuildListNotification(result OpenResult) ([]byte, error) {
return buildMailboxListNotification(result.Mails, result.NotLoaded, result.ObservedAt)
}
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: append([]byte(nil), body...),
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
func class0Response(messageID uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: messageID,
Body: append([]byte(nil), body...),
Classification: 0,
AllowCodec: true,
}
}
func buildMailboxOpenAck(total int) []byte {
if total < 0 {
total = 0
}
if total > 0xffff {
total = 0xffff
}
body := []byte{0x01, 0x00, 0x00}
binary.LittleEndian.PutUint16(body[1:], uint16(total))
return body
}
func buildSuccessAck() []byte {
return []byte{0x01}
}
// buildExtractAck writes the op95 acknowledgement: success flag, u32 result
// count, then three u32 per result.
//
// sub_1D225F0 collects those triples and hands them to sub_27156D0, which reads
// them as (letter id, claim object, mode):
//
// - the first dword is the container key, so it must be the letter id.
// - the second dword is passed to sub_2708220(entry, claimObject), which scans
// the entry's claim-object vector at +0x70 and returns the paired item object
// at +0x84. It has to be the same claim object the summary row gave that
// slot; with the letter id in both places a multi-attachment letter only ever
// retires its first slot, and a zero here never matches at all.
// - the third dword's low word must stay zero; a non-zero value diverts to
// sub_27154E0 instead of the normal extraction path.
//
// A key or claim object that does not resolve is harmless - the applier just
// advances to the next triple - so a stale row can never fault the client.
func buildExtractAck(claimed []ClaimedAttachment) []byte {
body := make([]byte, 1+4+len(claimed)*12)
body[0] = 0x01
binary.LittleEndian.PutUint32(body[1:5], uint32(len(claimed)))
offset := 5
for _, row := range claimed {
binary.LittleEndian.PutUint32(body[offset:offset+4], row.LetterID)
binary.LittleEndian.PutUint32(body[offset+4:offset+8], row.ClaimObject)
offset += 12
}
return body
}
func buildChangeStateAck(mailIDs []uint32, status uint16) []byte {
body := make([]byte, 1+4+len(mailIDs)*6)
body[0] = 0x01
binary.LittleEndian.PutUint32(body[1:5], uint32(len(mailIDs)))
offset := 5
for _, mailID := range mailIDs {
binary.LittleEndian.PutUint32(body[offset:offset+4], mailID)
binary.LittleEndian.PutUint16(body[offset+4:offset+6], status)
offset += 6
}
return body
}
func buildQueryCharacterAck(nameRaw []byte, grow byte, level uint16, job byte) []byte {
body := make([]byte, 0, 1+4+len(nameRaw)+8)
body = append(body, 0x01)
var scratch [4]byte
binary.LittleEndian.PutUint32(scratch[:], uint32(len(nameRaw)))
body = append(body, scratch[:]...)
body = append(body, nameRaw...)
body = append(body, grow)
var levelBytes [2]byte
binary.LittleEndian.PutUint16(levelBytes[:], level)
body = append(body, levelBytes[:]...)
// sub_1D0BAD0 reads job plus four trailing state bytes after level. The
// 1 in the third state field matches the handler's initialized normal state.
body = append(body, job, 0x00, 0x01, 0x00, 0x00)
return body
}
func buildRecipientCharacterListBody(serverID byte, characters []dnfrepo.CharacterRecord, selectedCharacterID uint16) ([]byte, error) {
writer := mailboxBodyWriter{}
writer.u8(serverID)
writer.u8(0)
count := 0
for _, character := range characters {
if count == math.MaxUint8 {
break
}
characterID, err := strconv.ParseUint(strings.TrimSpace(character.CharacterID), 10, 32)
if err != nil || characterID == 0 {
return nil, fmt.Errorf("%w: invalid mailbox account-role character id %q", ErrMailboxInvalidRequest, character.CharacterID)
}
if uint32(characterID) == uint32(selectedCharacterID) {
continue
}
name := strings.TrimSpace(character.Name)
if name == "" {
return nil, fmt.Errorf("%w: mailbox account-role character %d has no name", ErrMailboxInvalidRequest, characterID)
}
nameRaw, err := encodeMailboxText(name, mailboxRecipientListNameMaxBytes)
if err != nil {
return nil, fmt.Errorf("%w: mailbox account-role name %q: %v", ErrMailboxInvalidRequest, name, err)
}
writer.u32(uint32(characterID))
writer.u32(uint32(len(nameRaw)))
writer.raw(nameRaw)
writer.u8(0)
count++
}
writer.data[1] = byte(count)
return writer.bytes(), nil
}
func buildErrorAck(code byte) []byte {
if code == 0 {
code = 1
}
return []byte{0x00, code}
}
// buildMailboxListNotification follows the current client's
// sub_1D913C0 reader exactly: attachment/gold summaries, the deferred-page
// count, then the detailed text/status rows. The common 0x77 item row is
// deliberately retained as a bounded opaque client item row; its front fields
// are projected separately because the client reads both representations.
func buildMailboxListNotification(mails []dnfrepo.MailRecord, notLoaded int, now time.Time) ([]byte, error) {
if len(mails) > math.MaxUint16 {
return nil, fmt.Errorf("%w: mailbox page has %d mails", ErrMailboxInvalidRequest, len(mails))
}
summaries := make([]mailboxSummary, 0, len(mails))
for _, durableMail := range mails {
mail := mailboxPresentationMail(durableMail)
mailID, err := mailboxWireID(mail.MailID)
if err != nil {
return nil, err
}
wrote := false
for index, attachment := range mail.Attachments {
// An empty slot is one that has already been handed over. Skip it
// instead of rejecting the page: one unusable row must never cost the
// player the whole mailbox, which is exactly what happened when such
// rows reached this builder and every op96 answered
// "mailbox snapshot unavailable".
if attachment.ItemID <= 0 || attachment.Count <= 0 {
continue
}
if len(summaries) >= math.MaxUint8 {
return nil, fmt.Errorf("%w: mailbox page has more than %d attachment summaries", ErrMailboxInvalidRequest, math.MaxUint8)
}
summaries = append(summaries, mailboxSummary{
mail: mail,
mailID: mailID,
claimObject: mailboxSlotClaimObject(mailID, index),
attachment: &attachment,
includeGold: !wrote,
// A wallet-currency voucher is money the client has to print:
// its cell's amount comes from this slot, not from the item row's
// count (see mailboxAttachmentWireAmount).
amount: mailboxAttachmentWireAmount(attachment),
})
wrote = true
}
if !wrote && mail.Gold > 0 {
if len(summaries) >= math.MaxUint8 {
return nil, fmt.Errorf("%w: mailbox page has more than %d summaries", ErrMailboxInvalidRequest, math.MaxUint8)
}
summaries = append(summaries, mailboxSummary{mail: mail, mailID: mailID, claimObject: mailID, includeGold: true})
wrote = true
}
// The client keeps the current message identity from the summary pass.
// A text-only mail therefore needs one intentionally empty seed record.
if !wrote {
if len(summaries) >= math.MaxUint8 {
return nil, fmt.Errorf("%w: mailbox page has more than %d summaries", ErrMailboxInvalidRequest, math.MaxUint8)
}
summaries = append(summaries, mailboxSummary{mail: mail, mailID: mailID, claimObject: mailID, seedOnly: true})
}
}
writer := mailboxBodyWriter{}
writer.u8(byte(len(summaries)))
// Zero makes sub_1D913C0 clear both current mail containers before this
// complete snapshot; subsequent page batches would use one here.
writer.u8(0)
for _, summary := range summaries {
if err := writer.writeSummary(summary, now); err != nil {
return nil, err
}
}
writer.u16(clampMailboxU16(notLoaded))
writer.u16(uint16(len(mails)))
for _, durableMail := range mails {
mail := mailboxPresentationMail(durableMail)
if err := writer.writeDetail(mail); err != nil {
return nil, err
}
}
return writer.bytes(), nil
}
// mailboxPresentationMail projects old full-inventory system mail with the
// current Chinese copy. Claimed mail is always projected as an empty, read
// receipt; this also protects the UI while an older durable row is being
// repaired by Owner.Open.
func mailboxPresentationMail(mail dnfrepo.MailRecord) dnfrepo.MailRecord {
switch strings.TrimSpace(mail.Metadata["source"]) {
case "magic_box_reward_inventory_full":
mail.SenderName = "系统"
mail.Title = "背包已满:礼盒奖励"
mail.Body = "背包空间不足,礼盒奖励已通过邮件发送。请清理对应道具分页后领取。"
case "dungeon_card_reward_inventory_full":
mail.SenderName = "系统"
mail.Title = "背包已满:通关奖励"
mail.Body = "背包空间不足,通关奖励已通过邮件发送。请清理对应道具分页后领取。"
}
if mail.Claimed {
mail.Gold = 0
mail.Attachments = nil
}
return mail
}
func buildMailboxRemoveNotification(mailIDs []uint32) []byte {
writer := mailboxBodyWriter{}
writer.u32(uint32(len(mailIDs)))
for _, mailID := range mailIDs {
writer.u32(mailID)
}
return writer.bytes()
}
// buildMailboxAlarmNotification is the exact one-WORD class0/0x63 body. It
// is kept here for the session directory path, which may notify an online
// recipient independently from the sender's command response.
func buildMailboxAlarmNotification(count int) []byte {
writer := mailboxBodyWriter{}
writer.u16(clampMailboxU16(count))
return writer.bytes()
}
// BuildAlarmNotification produces the current class0/0x63 one-WORD payload
// used when a recipient already has an active game session.
func BuildAlarmNotification(count int) []byte {
return buildMailboxAlarmNotification(count)
}
type mailboxSummary struct {
mail dnfrepo.MailRecord
mailID uint32
claimObject uint32
attachment *dnfrepo.MailAttachment
includeGold bool
// amount is the money this one attachment cell is worth, for the attachment
// kinds the client renders as a number. The letter's own gold wins when this
// row carries it (includeGold).
amount uint32
seedOnly bool
}
// mailboxAttachmentWireAmount is the amount the client prints on one attachment
// cell. The current op0x61 reader takes it from the summary's gold slot, NOT
// from the item row's count:
//
// FUN_034e3a50(&gold); ; the summary's amount slot
// FUN_034e3a50(&itemID);
// if (itemID - 0x28eb7dU < 9) gold = (itemID - 0x28eb7c) * 1000000; ; gold_1m..gold_9m
// else if (itemID - 0x28eb86U < 3) gold = (itemID - 0x28eb85) * 10000000; ; gold_10m..30m
// ...
// FUN_0270bfb0(0, claim, .., gold, ..); ; that value becomes the row's amount
//
// so a gold certificate is priced by the client itself from its item id, while
// every other cell shows whatever this slot carried. A 代币券 attachment is money
// too - it is credited to the account wallet on claim - and with a zero here the
// seller's sale letter showed an icon and "0" next to it.
func mailboxAttachmentWireAmount(attachment dnfrepo.MailAttachment) uint32 {
// VoucherKindOf is the same predicate the claim path uses to route these
// attachments to the account wallet, so presentation and payment can never
// disagree about which items are money.
if dnfcerashop.VoucherKindOf(attachment.ItemID) == dnfcerashop.VoucherNone {
return 0
}
if attachment.Count <= 0 || attachment.Count > math.MaxUint32 {
return 0
}
return uint32(attachment.Count)
}
// mailboxMaxAttachmentsPerMail is 86JP's per-letter attachment cap, so four bits
// address every slot of a letter.
const mailboxMaxAttachmentsPerMail = 16
// MailboxAttachmentSlotFlag marks a claim object that addresses one attachment
// slot instead of the whole letter.
//
// 86JP uses 0x40000000 (MailboxRepository.AttachmentClaimFlag) and documents the
// contract this depends on: the client merges summary rows by the letter id near
// the end of the row and gives every distinct leading dword its own attachment
// slot. Writing the letter id in both fields is what collapses a letter into one
// visible item. The flag itself is moved below the reference's value because the
// client faulted in its op95 handler while ids above 0x40000000 were on the wire;
// real mail ids stay far below this range, so an encoded slot can never be
// mistaken for a letter.
const MailboxAttachmentSlotFlag uint32 = 0x10000000
// MailboxAttachmentClaimObject encodes one attachment slot: the flag, the letter
// id, then the slot index in the low four bits.
func MailboxAttachmentClaimObject(mailID uint32, index int) (uint32, error) {
if index < 0 || index >= mailboxMaxAttachmentsPerMail {
return 0, fmt.Errorf("%w: attachment index %d is outside 0..%d", ErrMailboxInvalidRequest, index, mailboxMaxAttachmentsPerMail-1)
}
if mailID == 0 || mailID > (MailboxAttachmentSlotFlag-1)/mailboxMaxAttachmentsPerMail {
return 0, fmt.Errorf("%w: mail id %d cannot carry an attachment claim object", ErrMailboxInvalidRequest, mailID)
}
return MailboxAttachmentSlotFlag | (mailID * mailboxMaxAttachmentsPerMail) | uint32(index), nil
}
// mailboxSlotClaimObject is the addressing used on the wire. A slot that cannot
// be encoded - past the sixteenth attachment, or a letter id too large to carry
// an index - falls back to the letter id instead of failing. Such a row then
// merges into the letter's first slot exactly as it did before slots existed,
// which costs that one row its own icon; failing here would cost the player the
// whole mailbox page.
func mailboxSlotClaimObject(mailID uint32, index int) uint32 {
claimObject, err := MailboxAttachmentClaimObject(mailID, index)
if err != nil {
return mailID
}
return claimObject
}
// DecodeMailboxClaimObject splits one op95 entry. A value below the flag still
// addresses the whole letter, which is what the client sends for a gold-only or
// text-only row and what every older client build sends.
func DecodeMailboxClaimObject(claimObject uint32) (mailID uint32, attachmentIndex int, singleSlot bool) {
if claimObject < MailboxAttachmentSlotFlag {
return claimObject, 0, false
}
value := claimObject &^ MailboxAttachmentSlotFlag
return value / mailboxMaxAttachmentsPerMail, int(value % mailboxMaxAttachmentsPerMail), true
}
type mailboxBodyWriter struct{ data []byte }
func (w *mailboxBodyWriter) u8(value byte) { w.data = append(w.data, value) }
func (w *mailboxBodyWriter) u16(value uint16) {
var encoded [2]byte
binary.LittleEndian.PutUint16(encoded[:], value)
w.data = append(w.data, encoded[:]...)
}
func (w *mailboxBodyWriter) u32(value uint32) {
var encoded [4]byte
binary.LittleEndian.PutUint32(encoded[:], value)
w.data = append(w.data, encoded[:]...)
}
func (w *mailboxBodyWriter) raw(value []byte) { w.data = append(w.data, value...) }
// optionalBlob writes the current client's `u32 length + payload` extension
// block (sub_3457C50: one sub_34E3A50 length followed by a sub_34E3AC0 read of
// exactly that many bytes). An absent payload is written as a zero length, which
// leaves the client's freshly constructed target object at its defaults.
func (w *mailboxBodyWriter) optionalBlob(data []byte) {
if len(data) == 0 || len(data) > math.MaxUint32 {
w.u32(0)
return
}
w.u32(uint32(len(data)))
w.raw(data)
}
func (w *mailboxBodyWriter) dstring(value string, maxBytes int) error {
raw, err := encodeMailboxText(value, maxBytes)
if err != nil {
return err
}
w.u32(uint32(len(raw)))
w.raw(raw)
return nil
}
func (w *mailboxBodyWriter) writeSummary(summary mailboxSummary, now time.Time) error {
senderID, err := mailboxSenderWireID(summary.mail.SenderCharacterID)
if err != nil {
return err
}
// First dword: the claim object, which must be distinct per attachment.
//
// The client keys mailbox entries by the letter id at the end of this row and
// merges same-letter rows through sub_2709990. That merge keeps two parallel
// vectors on the entry - claim objects at +0x70 and their item objects at
// +0x84 - and appends the pair only after scanning +0x70 for the incoming
// claim object: on a hit it aborts the whole merge (`xor al,al; ret`). Writing
// the letter id into every row of a letter therefore collapses it to a single
// visible attachment, which is exactly the "one item per mail" defect.
w.u32(summary.claimObject)
w.u32(senderID)
if err := w.dstring(summary.mail.SenderName, maxMailboxRecipientBytes); err != nil {
return fmt.Errorf("sender name: %w", err)
}
// This slot is the cell's amount as far as the client is concerned - it
// prints it beside the cell's icon and never reads the item row's count for
// it (see mailboxAttachmentWireAmount). A row that owns the letter's gold
// shows that gold; every other row shows its own amount, and only the
// wallet-currency vouchers have one.
amount := summary.amount
if summary.includeGold {
if summary.mail.Gold < 0 || summary.mail.Gold > math.MaxUint32 {
return fmt.Errorf("%w: mail=%d gold=%d", ErrMailboxInvalidRequest, summary.mailID, summary.mail.Gold)
}
if summary.mail.Gold > 0 {
amount = uint32(summary.mail.Gold)
}
}
w.u32(amount)
if summary.attachment == nil {
w.u32(0)
w.u8(0)
w.u32(0)
w.u16(0)
w.u8(0)
w.u32(0)
w.u8(0)
w.u8(0)
w.u16(0)
w.raw(make([]byte, mailboxItemRawSize))
} else {
attachment := *summary.attachment
if attachment.ItemID > math.MaxUint32 || attachment.Count > math.MaxUint32 {
return fmt.Errorf("%w: attachment item/count outside wire range", ErrMailboxInvalidRequest)
}
raw := mailboxAttachmentRaw(attachment, now)
w.u32(uint32(attachment.ItemID))
w.u8(1)
w.u32(binary.LittleEndian.Uint32(raw[6:10]))
w.u16(binary.LittleEndian.Uint16(raw[0x0b:0x0d]))
w.u8(raw[0x0d])
w.u32(binary.LittleEndian.Uint32(raw[0x0e:0x12]))
w.u8(raw[0x12])
w.u8(raw[0x13])
w.u16(binary.LittleEndian.Uint16(raw[0x14:0x16]))
if mailboxAttachmentRequiresClass26(attachment) {
// The current type-26 branch consumes this DWORD and its detail flag
// before the fixed item row. We have no durable creature-detail UOW,
// so the explicit zero flag requests only the proven generic item
// projection instead of fabricating the three detail dwords.
w.u32(0)
w.u8(0)
}
w.raw(raw)
if mailboxAttachmentRequiresAvatarBlobs(attachment) {
// Current sub_1D913C0 keeps reading after the fixed 0x77 row when
// the attachment's item-table type is 0..11: sub_229C570 gates the
// avatar branch on that range and calls sub_3457C50 for the jewel
// socket, then sub_225C9B0(.., itemID, 3) re-tests the same range
// through sub_229ACD0 and reads the colour block. Both are the
// `u32 length + payload` extension already written for list-1 item
// rows, and a zero length keeps the client's constructed defaults.
w.optionalBlob(mailboxAttachmentFixedHexBlob(
attachment,
mailboxAvatarSocketBytes,
"avatar_socket_data", "reserved2", "jewel_socket", "jewelSocket",
))
w.optionalBlob(mailboxAttachmentFixedHexBlob(
attachment,
mailboxAvatarColorBytes,
"avatar_color_data", "color_data", "tail_data", "tailData",
))
}
}
w.u32(mailboxRemainingSeconds(summary.mail, now))
if summary.seedOnly {
w.u32(0)
} else {
w.u32(summary.mailID)
}
w.u8(mailboxMailType(summary.mail))
return nil
}
func (w *mailboxBodyWriter) writeDetail(mail dnfrepo.MailRecord) error {
mailID, err := mailboxWireID(mail.MailID)
if err != nil {
return err
}
senderID, err := mailboxSenderWireID(mail.SenderCharacterID)
if err != nil {
return err
}
w.u32(mailID)
w.u32(senderID)
if err := w.dstring(mail.SenderName, maxMailboxRecipientBytes); err != nil {
return fmt.Errorf("sender name: %w", err)
}
if err := w.dstring(mail.Body, maxMailboxMessageBytes); err != nil {
return fmt.Errorf("body: %w", err)
}
w.u32(mailboxUnix(mail.CreatedAt))
w.u16(mailboxLetterStat(mail))
w.u8(mailboxMailType(mail))
return nil
}
func (w mailboxBodyWriter) bytes() []byte { return append([]byte(nil), w.data...) }
func mailboxAttachmentRaw(attachment dnfrepo.MailAttachment, now time.Time) []byte {
raw := make([]byte, mailboxItemRawSize)
if len(attachment.RawEntry) == mailboxItemRawSize {
copy(raw, attachment.RawEntry)
}
binary.LittleEndian.PutUint16(raw[0:2], 0)
binary.LittleEndian.PutUint32(raw[2:6], uint32(attachment.ItemID))
binary.LittleEndian.PutUint32(raw[6:10], uint32(attachment.Count))
if attachment.Bind && raw[0x0d] == 0 {
raw[0x0d] = 1
}
if !attachment.ExpireAt.IsZero() && attachment.ExpireAt.Unix() > 0 {
binary.LittleEndian.PutUint32(raw[0x38:0x3c], mailboxUnix(attachment.ExpireAt))
if mailboxAttachmentIsStackable(attachment) {
binary.LittleEndian.PutUint16(raw[0x0b:0x0d], mailboxAttachmentRemainingUseSeconds(attachment.ExpireAt, now))
}
}
return raw
}
func mailboxAttachmentIsStackable(attachment dnfrepo.MailAttachment) bool {
return strings.EqualFold(strings.TrimSpace(attachment.Extra["item_kind"]), "stackable") ||
strings.TrimSpace(attachment.Extra["stackable_type"]) != ""
}
func mailboxAttachmentRemainingUseSeconds(expiresAt time.Time, now time.Time) uint16 {
if expiresAt.IsZero() || now.IsZero() || !expiresAt.After(now) {
return 0
}
remaining := expiresAt.Sub(now)
seconds := int64(remaining / time.Second)
if remaining%time.Second != 0 {
seconds++
}
if seconds > math.MaxUint16 {
return math.MaxUint16
}
return uint16(seconds)
}
func mailboxAttachmentRequiresClass26(attachment dnfrepo.MailAttachment) bool {
return mailboxAttachmentEquipmentType(attachment) == "creature"
}
// mailboxAttachmentRequiresAvatarBlobs reports whether the current client will
// consume the two trailing extension blocks for this attachment. Current
// sub_229C570/sub_229ACD0 both gate them on item-table type <= 11, which is the
// avatar family the PVF names `[hat avatar]` .. `[weapon avatar]`; `[creature]`
// is 26 and ordinary equipment starts at 12, so neither reads them.
func mailboxAttachmentRequiresAvatarBlobs(attachment dnfrepo.MailAttachment) bool {
return strings.HasSuffix(mailboxAttachmentEquipmentType(attachment), " avatar")
}
// mailboxAttachmentEquipmentType normalizes the durable PVF `[equipment type]`
// text kept on the attachment. The runtime PVF stores it as a backtick-quoted
// bracket tag such as `[weapon avatar]`, while older rows and tests carry the
// bare token, so both forms must resolve to the same category.
func mailboxAttachmentEquipmentType(attachment dnfrepo.MailAttachment) string {
tag := strings.ToLower(strings.TrimSpace(attachment.Extra["mailbox_equipment_type"]))
tag = strings.TrimSpace(strings.Trim(tag, "`"))
tag = strings.TrimPrefix(tag, "[")
tag = strings.TrimSuffix(tag, "]")
return strings.TrimSpace(tag)
}
// mailboxAttachmentFixedHexBlob decodes the first present hex-encoded extra and
// returns exactly size bytes. The current client reads these blocks into fixed
// targets - a 30-byte jewel socket built by sub_3775940 and the single colour
// DWORD built by sub_3775970 and read back as one dword - so an over-long
// durable value must never reach the wire.
func mailboxAttachmentFixedHexBlob(attachment dnfrepo.MailAttachment, size int, keys ...string) []byte {
for _, key := range keys {
raw := strings.TrimSpace(attachment.Extra[key])
if raw == "" {
continue
}
raw = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "", "0x", "", "0X", "").Replace(raw)
decoded, err := hex.DecodeString(raw)
if err != nil || len(decoded) == 0 {
continue
}
fixed := make([]byte, size)
copy(fixed, decoded)
return fixed
}
return nil
}
func mailboxLetterStat(mail dnfrepo.MailRecord) uint16 {
if mailboxMailSaved(mail) {
return 3
}
if mail.Read {
return 2
}
return 1
}
func mailboxMailType(mail dnfrepo.MailRecord) byte {
value, err := strconv.ParseUint(strings.TrimSpace(mail.Metadata["mail_type"]), 10, 8)
if err != nil {
return 0
}
return byte(value)
}
func mailboxRemainingSeconds(mail dnfrepo.MailRecord, now time.Time) uint32 {
if mailboxMailSaved(mail) || mailboxMailUnlimited(mail) || now.IsZero() {
return 0
}
expiresAt := mail.ExpireAt
if expiresAt.IsZero() && !mail.CreatedAt.IsZero() {
expiresAt = mail.CreatedAt.Add(mailboxNormalLifetime)
}
if expiresAt.IsZero() || !expiresAt.After(now) {
return 0
}
remaining := int64(expiresAt.Sub(now) / time.Second)
if remaining > math.MaxUint32 {
return math.MaxUint32
}
return uint32(remaining)
}
func mailboxWireID(value string) (uint32, error) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
if err != nil || parsed == 0 {
return 0, fmt.Errorf("%w: invalid wire mail id %q", ErrMailboxInvalidRequest, value)
}
return uint32(parsed), nil
}
func mailboxSenderWireID(value string) (uint32, error) {
if strings.TrimSpace(value) == "" {
return 0, nil
}
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
if err != nil {
return 0, fmt.Errorf("%w: invalid sender id %q", ErrMailboxInvalidRequest, value)
}
return uint32(parsed), nil
}
func mailboxUnix(value time.Time) uint32 {
if value.IsZero() || value.Unix() <= 0 {
return 0
}
if value.Unix() > math.MaxUint32 {
return math.MaxUint32
}
return uint32(value.Unix())
}
func clampMailboxU16(value int) uint16 {
if value <= 0 {
return 0
}
if value >= math.MaxUint16 {
return math.MaxUint16
}
return uint16(value)
}
func encodeMailboxText(value string, maxBytes int) ([]byte, error) {
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(value))
if err != nil {
return nil, err
}
if len(encoded) > maxBytes {
return nil, fmt.Errorf("length %d exceeds %d", len(encoded), maxBytes)
}
return encoded, nil
}
@@ -0,0 +1,151 @@
package mail
// The amount a mailbox cell prints comes from the op0x61 summary's gold slot,
// not from the item row's count. The current reader takes it there and only
// overrides it for the gold certificates it can price from the item id:
//
// FUN_034e3a50(&gold); ; the summary's amount slot
// FUN_034e3a50(&itemID);
// if (itemID - 0x28eb7dU < 9) gold = (itemID - 0x28eb7c) * 1000000;
// else if (itemID - 0x28eb86U < 3) gold = (itemID - 0x28eb85) * 10000000;
// FUN_0270bfb0(0, claim, .., gold, ..);
//
// so a 代币券 attachment - money, credited to the account wallet on claim - must
// carry its count in that slot, or the seller's sale letter shows an icon and a
// zero. This is what the live consignment letter did.
import (
"encoding/binary"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// mailboxSummaryWireFields is one decoded summary row of the op0x61 page.
type mailboxSummaryWireFields struct {
claimObject uint32
amount uint32
itemID uint32
itemCount uint32
mailID uint32
}
func parseMailboxSummaryWire(t *testing.T, body []byte) []mailboxSummaryWireFields {
t.Helper()
if len(body) < 2 {
t.Fatalf("mailbox page too short: %d", len(body))
}
count := int(body[0])
off := 2 // summary count + clear-first flag
out := make([]mailboxSummaryWireFields, 0, count)
readU32 := func() uint32 {
if off+4 > len(body) {
t.Fatalf("mailbox page ran out at %d", off)
}
v := binary.LittleEndian.Uint32(body[off : off+4])
off += 4
return v
}
for index := 0; index < count; index++ {
var fields mailboxSummaryWireFields
fields.claimObject = readU32()
readU32() // sender wire id
nameLen := int(readU32())
off += nameLen
fields.amount = readU32()
fields.itemID = readU32()
off++ // has-item byte
fields.itemCount = readU32()
off += 2 + 1 + 4 + 1 + 1 + 2 // use count, bind, dword, two bytes, u16
off += mailboxItemRawSize
off += 4 // remaining seconds
fields.mailID = readU32()
off++ // mail type
out = append(out, fields)
}
return out
}
func TestMailboxPagePricesWalletVouchersInTheCellAmountSlot(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
mail := dnfrepo.MailRecord{
MailID: "21",
SenderName: "系统",
Title: "- 已成功拍卖[1百万] -",
Body: "成交价 : + 188 代币券",
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{{ItemID: 2681762, Count: 132}},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatal(err)
}
summaries := parseMailboxSummaryWire(t, body)
if len(summaries) != 1 {
t.Fatalf("summaries=%d want 1", len(summaries))
}
got := summaries[0]
if got.itemID != 2681762 || got.itemCount != 132 {
t.Fatalf("voucher row item=%d count=%d want 2681762/132", got.itemID, got.itemCount)
}
if got.amount != 132 {
t.Fatalf("voucher cell amount=%d want the 代币券 count 132; the client prints this slot, not the item count", got.amount)
}
// The happy-token voucher is money too.
happy := mail
happy.MailID = "22"
happy.Attachments = []dnfrepo.MailAttachment{{ItemID: 2681917, Count: 7}}
body, err = buildMailboxListNotification([]dnfrepo.MailRecord{happy}, 0, now)
if err != nil {
t.Fatal(err)
}
if got := parseMailboxSummaryWire(t, body)[0]; got.amount != 7 {
t.Fatalf("happy voucher cell amount=%d want 7", got.amount)
}
// An ordinary item is not money: its cell keeps the zero amount and the
// client draws the item itself, so the count must not leak into this slot.
ordinary := mail
ordinary.MailID = "23"
ordinary.Attachments = []dnfrepo.MailAttachment{{ItemID: 28309, Count: 48}}
body, err = buildMailboxListNotification([]dnfrepo.MailRecord{ordinary}, 0, now)
if err != nil {
t.Fatal(err)
}
if got := parseMailboxSummaryWire(t, body)[0]; got.amount != 0 {
t.Fatalf("ordinary item cell amount=%d want 0", got.amount)
}
}
// A letter that carries gold as well keeps the gold on the row that owns it; the
// voucher row prices itself, and a single row cannot be both.
func TestMailboxPageKeepsLetterGoldAndVoucherAmountApartOnTheSameLetter(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
mail := dnfrepo.MailRecord{
MailID: "31",
SenderName: "系统",
Title: "混合信件",
Body: "测试",
Gold: 19_500,
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{{ItemID: 2681762, Count: 132}},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatal(err)
}
summaries := parseMailboxSummaryWire(t, body)
if len(summaries) != 1 {
t.Fatalf("summaries=%d want 1", len(summaries))
}
// The first (and here only) row owns the letter's gold, so that is the amount
// it must print; the voucher count stays on the item row beside it.
if summaries[0].amount != 19_500 {
t.Fatalf("mixed letter cell amount=%d want the letter gold 19500", summaries[0].amount)
}
if summaries[0].itemCount != 132 {
t.Fatalf("mixed letter item count=%d want 132", summaries[0].itemCount)
}
}
@@ -0,0 +1,240 @@
package mail
import (
"context"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
const (
claimContractItemID int64 = 10000391
claimContractType int64 = 3
claimContractSeconds int64 = 86400
)
func claimContractResolver() alignedcmd.PremiumContractResolver {
return func(itemID int64) (alignedcmd.PremiumContractResolution, error) {
if itemID != claimContractItemID {
return alignedcmd.PremiumContractResolution{}, nil
}
return alignedcmd.PremiumContractResolution{
ItemID: itemID,
PremiumType: claimContractType,
DurationSeconds: claimContractSeconds,
}, nil
}
}
// claimContractRepos builds a character whose consumable tab is completely full,
// which is the state every one of these letters is claimed in.
func claimContractRepos(t *testing.T, ctx context.Context, attachments []dnfrepo.MailAttachment) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-a"}); err != nil {
t.Fatalf("save account: %v", err)
}
slots := map[string]dnfrepo.ItemStack{
dnfrevivecoin.WalletKey(): {
ItemID: dnfrevivecoin.WalletItemID,
Count: 4,
Extra: map[string]string{"item_kind": "stackable", "revive_coin_wallet": "1"},
},
}
for slot := int16(3); slot <= 8; slot++ {
slots[fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 800000 + int64(slot), Count: 1}
}
for slot := int16(65); slot <= 120; slot++ {
slots[fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 900000 + int64(slot), Count: 1}
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{"1": {MailID: "1", Attachments: attachments}},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
return repos
}
func claimContractRequest(repos dnfrepo.Group, claimObject uint32) alignedcmd.Request {
return alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
AccountID: "acc-a",
Repositories: repos,
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 0),
PremiumContractResolver: claimContractResolver(),
Body: currentMailIDList(claimObject),
}
}
// TestMailboxClaimActivatesContractWithoutASlot proves a mailed 契约 is delivered
// by activating it: the account premium term grows, no backpack slot is used and
// the claim succeeds against a completely full consumable tab.
func TestMailboxClaimActivatesContractWithoutASlot(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: claimContractItemID, Count: 2},
})
claimObject, err := MailboxAttachmentClaimObject(1, 0)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(repos, claimObject))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) == 2 && body[0] == 0x00 {
t.Fatalf("contract claim was rejected with code %d; a contract needs no slot", body[1])
}
account, found, err := repos.Account.Load(ctx, "acc-a")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
if expireAt := premium.ExpireAt(account, claimContractType); expireAt < time.Now().Unix()+claimContractSeconds {
t.Fatalf("premium expiry = %d, want both contract days", expireAt)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
for key, stack := range inventory.Slots {
if stack.ItemID == claimContractItemID {
t.Fatalf("the contract must not occupy a slot: %s %+v", key, stack)
}
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if mail := box.Mails["1"]; !mail.Claimed {
t.Fatalf("the letter's only slot was claimed, so it must close: %+v", mail)
}
// Granting the term is only half the delivery: without the activation
// notification the client keeps drawing the old remaining time, which reads in
// game as a contract that added no days at all.
notice, found := mailboxPremiumActivation(got)
if !found {
t.Fatalf("a claimed contract must send the activation notification: %+v", got.UpperResponses)
}
if notice.Body[2] != byte(claimContractType) {
t.Fatalf("notification premium type = %d, want %d", notice.Body[2], claimContractType)
}
remaining := int64(0)
for shift := 0; shift < 8; shift++ {
remaining |= int64(notice.Body[3+shift]) << (8 * shift)
}
if remaining < 2*claimContractSeconds {
t.Fatalf("notified remaining = %d, want both contract days", remaining)
}
}
// mailboxPremiumActivation finds the class0 premium activation notification in a
// claim response.
func mailboxPremiumActivation(got alignedcmd.Result) (alignedcmd.UpperResponse, bool) {
for _, response := range got.UpperResponses {
if response.MsgID == premium.ActivatedNotificationMessageID && len(response.Body) == 11 {
return response, true
}
}
return alignedcmd.UpperResponse{}, false
}
// TestMailboxClaimCreditsReviveCoinWallet proves a mailed 复活币 goes to the fixed
// slot-1 wallet instead of demanding a consumable slot.
func TestMailboxClaimCreditsReviveCoinWallet(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: dnfrevivecoin.ConsumableItemID, Count: 7},
})
claimObject, err := MailboxAttachmentClaimObject(1, 0)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(repos, claimObject))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) == 2 && body[0] == 0x00 {
t.Fatalf("revive-coin claim was rejected with code %d; the wallet has room", body[1])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
wallet := inventory.Slots[dnfrevivecoin.WalletKey()]
if wallet.ItemID != dnfrevivecoin.WalletItemID || wallet.Count != 11 {
t.Fatalf("wallet = %+v, want item %d count 11", wallet, dnfrevivecoin.WalletItemID)
}
for key, stack := range inventory.Slots {
if stack.ItemID == dnfrevivecoin.ConsumableItemID {
t.Fatalf("the coin consumable must not be stored: %s %+v", key, stack)
}
}
}
// TestMailboxClaimKeepsContractWhenAnotherSlotBlocksTheLetter proves the staging
// is per letter: a whole-letter claim that cannot place its ordinary item must
// not grant the contract either.
func TestMailboxClaimKeepsContractWhenAnotherSlotBlocksTheLetter(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: claimContractItemID, Count: 1},
{ItemID: 2001, Count: 1},
})
got, err := NewHandler().Handle(ctx, claimContractRequest(repos, 1))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
account, _, _ := repos.Account.Load(ctx, "acc-a")
if expireAt := premium.ExpireAt(account, claimContractType); expireAt != 0 {
t.Fatalf("premium expiry = %d, want none for a refused letter", expireAt)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed || len(mail.Attachments) != 2 {
t.Fatalf("a refused letter must keep both attachments: %+v", mail)
}
}
// TestMailboxClaimContractSlotLeavesTheBlockedSlotBehind is the practical case:
// the contract slot is claimed on its own while the item that has nowhere to go
// stays in the letter at its original position.
func TestMailboxClaimContractSlotLeavesTheBlockedSlotBehind(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: claimContractItemID, Count: 1},
})
claimObject, err := MailboxAttachmentClaimObject(1, 1)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(repos, claimObject))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) == 2 && body[0] == 0x00 {
t.Fatalf("the contract slot was rejected with code %d", body[1])
}
account, _, _ := repos.Account.Load(ctx, "acc-a")
if expireAt := premium.ExpireAt(account, claimContractType); expireAt < time.Now().Unix() {
t.Fatalf("premium expiry = %d, want the contract activated", expireAt)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed {
t.Fatalf("the blocked slot keeps the letter claimable: %+v", mail)
}
if len(mail.Attachments) != 2 || mail.Attachments[0].ItemID != 2001 || mail.Attachments[1].ItemID != 0 {
t.Fatalf("attachments = %+v, want the contract slot blanked in place", mail.Attachments)
}
}
@@ -0,0 +1,53 @@
package mail
import (
"errors"
"fmt"
"testing"
)
// 这组用例钉住"锁定"这条拒绝在 op95 上到底回什么码,以及它**不能**被算成别的语义。
//
// 现场证据(2026-09-15,线上包 packet log,玩家在游戏里点领取那一刻):
//
// SEND kind=game-upper msg_id=95 body=0004
//
// 服务端确实回了 ackType 0x005F 的码 4,而客户端**没有画任何东西**。所以:
// - 码 4 在 op95 表里是可达的(老注释若说它渲染"背包已满"就与本机客户端不符),
// - 无论回哪个码,都不能再假设"客户端一定会自己画提示"。
//
// 这个用例只保证服务端这一侧稳定:锁定必须走锁定那条错误,且码值集中在一个 switch 里,
// 将来客户端码表反查清楚后只改一处。
func TestMailboxClaimErrorCodeKeepsLockedDistinct(t *testing.T) {
cases := []struct {
name string
err error
want byte
why string
}{
{"nil", nil, 0, "成功不回失败码"},
{"locked", ErrMailboxItemLocked, 4, "锁定走'放不进去'这一格(当前客户端不渲染,安全性不受影响)"},
{"locked wrapped", fmt.Errorf("%w: list=0 slot=65 item=2001", ErrMailboxItemLocked), 4, "带上文包装后仍要认出锁定"},
{"inventory full", ErrMailboxInventoryFull, 4, "背包放不下与锁定共用同一格"},
{"mail gone", ErrMailboxMailNotFound, 21, "信件不存在"},
{"already claimed", ErrMailboxAlreadyClaimed, 21, "已领取"},
{"expired", ErrMailboxExpired, 21, "已过期"},
{"gold", ErrMailboxGoldInsufficient, 10, "金币携带上限"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mailboxClaimErrorCode(tc.err); got != tc.want {
t.Fatalf("code=%d want=%d (%s)", got, tc.want, tc.why)
}
})
}
// 锁定永远不能被折叠成"成功"或"0":静默成功会把锁定附件真的发出去。
body := buildErrorAck(mailboxClaimErrorCode(fmt.Errorf("%w: x", ErrMailboxItemLocked)))
if len(body) != 2 || body[0] != 0x00 || body[1] == 0 {
t.Fatalf("锁定拒绝帧必须是非零失败码,实际 % X", body)
}
// 而且 must 不等于"没有错误"这条路径。
if errors.Is(ErrMailboxItemLocked, ErrMailboxInventoryFull) {
t.Fatal("锁定与背包已满是两个不同原因,不能互为别名")
}
}
@@ -0,0 +1,280 @@
package mail
import (
"bytes"
"context"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestMailboxAttachmentClaimObjectRoundTrip(t *testing.T) {
for _, mailID := range []uint32{1, 7, 52, 65535} {
for index := 0; index < mailboxMaxAttachmentsPerMail; index++ {
claimObject, err := MailboxAttachmentClaimObject(mailID, index)
if err != nil {
t.Fatalf("mail %d index %d: %v", mailID, index, err)
}
if claimObject < MailboxAttachmentSlotFlag {
t.Fatalf("claim object %d must carry the slot flag", claimObject)
}
gotMail, gotIndex, single := DecodeMailboxClaimObject(claimObject)
if !single || gotMail != mailID || gotIndex != index {
t.Fatalf("decode(%d) = (%d, %d, %t), want (%d, %d, true)", claimObject, gotMail, gotIndex, single, mailID, index)
}
}
}
if _, err := MailboxAttachmentClaimObject(7, mailboxMaxAttachmentsPerMail); err == nil {
t.Fatal("an out-of-range attachment index must be rejected")
}
// A plain mail id addresses the whole letter, which is what this client sends.
if gotMail, gotIndex, single := DecodeMailboxClaimObject(52); single || gotMail != 52 || gotIndex != 0 {
t.Fatalf("decode(52) = (%d, %d, %t), want the whole letter", gotMail, gotIndex, single)
}
}
func mailboxSlotTestRepos(t *testing.T, ctx context.Context, attachments []dnfrepo.MailAttachment) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{"1": {MailID: "1", Attachments: attachments}},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
return repos
}
func mailboxSlotClaim(repos dnfrepo.Group, claimObject uint32) alignedcmd.Request {
return alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
AccountID: "acc-a",
Repositories: repos,
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 0),
Body: currentMailIDList(claimObject),
}
}
// TestMailboxSnapshotKeepsOneRowPerAttachment pins what the page carries: one
// summary row per attachment, each with its own claim object and all of them
// keyed to the same letter. sub_2709990 merges the rows by that letter id but
// aborts the merge whenever the incoming claim object already sits in the
// entry's vector, so equal claim objects collapse a letter to one visible item.
func TestMailboxSnapshotKeepsOneRowPerAttachment(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
mail := dnfrepo.MailRecord{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
Body: "背包空间不足",
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{
{ItemID: 42, Count: 7},
{ItemID: 44, Count: 1},
{ItemID: 10000391, Count: 2},
},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatalf("build snapshot: %v", err)
}
if body[0] != 3 {
t.Fatalf("summary count = %d, want one row per attachment", body[0])
}
want, err := MailboxAttachmentClaimObject(52, 0)
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(body[2:6]); got != want {
t.Fatalf("leading dword = %d, want the first slot's claim object %d", got, want)
}
// Every slot must reach the client with its own claim object, or the merge
// drops it and the letter renders short.
for index := range mail.Attachments {
claimObject, err := MailboxAttachmentClaimObject(52, index)
if err != nil {
t.Fatal(err)
}
var encoded [4]byte
binary.LittleEndian.PutUint32(encoded[:], claimObject)
if !bytes.Contains(body, encoded[:]) {
t.Fatalf("slot %d claim object %d is missing from the page", index, claimObject)
}
}
}
// TestMailboxExtractClaimsOneSlot covers the slot-addressed claim the decoder
// supports: it delivers exactly that attachment, keeps the other slots at their
// original positions and leaves the letter claimable.
func TestMailboxExtractClaimsOneSlot(t *testing.T) {
ctx := context.Background()
repos := mailboxSlotTestRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: 2002, Count: 3},
{ItemID: 2003, Count: 1},
})
claimObject, err := MailboxAttachmentClaimObject(1, 1)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, mailboxSlotClaim(repos, claimObject))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) == 2 && body[0] == 0x00 {
t.Fatalf("slot claim was rejected with code %d", body[1])
}
// (count, letter id, claim object, mode) - the letter id keys the client's
// container and the claim object selects the slot inside it.
if binary.LittleEndian.Uint32(body[1:5]) != 1 ||
binary.LittleEndian.Uint32(body[5:9]) != 1 ||
binary.LittleEndian.Uint32(body[9:13]) != claimObject ||
binary.LittleEndian.Uint32(body[13:17]) != 0 {
t.Fatalf("ack must report the letter and the claimed object: % X", body)
}
// A letter with slots still pending is not retired from the client's list.
if len(got.PostItemSlotRefreshResponses) != 1 ||
!bytes.Equal(got.PostItemSlotRefreshResponses[0].Body, buildMailboxRemoveNotification(nil)) {
t.Fatalf("partial claim must not remove the letter: %+v", got.PostItemSlotRefreshResponses)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
for _, stack := range inventory.Slots {
if stack.ItemID == 2001 || stack.ItemID == 2003 {
t.Fatalf("only the addressed slot may be delivered: %+v", stack)
}
}
delivered := int64(0)
for _, stack := range inventory.Slots {
if stack.ItemID == 2002 {
delivered += stack.Count
}
}
if delivered != 3 {
t.Fatalf("claimed slot delivered %d, want 3", delivered)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed {
t.Fatalf("a letter with pending slots must stay claimable: %+v", mail)
}
if len(mail.Attachments) != 3 || mail.Attachments[0].ItemID != 2001 || mail.Attachments[1].ItemID != 0 || mail.Attachments[2].ItemID != 2003 {
t.Fatalf("attachments = %+v, want the claimed slot blanked in place", mail.Attachments)
}
}
// TestMailboxExtractWholeLetterClaimsEveryAttachment is what this client
// actually sends: the letter id, which hands over everything the letter holds
// and reports one row per slot.
func TestMailboxExtractWholeLetterClaimsEveryAttachment(t *testing.T) {
ctx := context.Background()
repos := mailboxSlotTestRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: 2002, Count: 1},
})
got, err := NewHandler().Handle(ctx, mailboxSlotClaim(repos, 1))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) != 5+2*12 || binary.LittleEndian.Uint32(body[1:5]) != 2 {
t.Fatalf("whole-letter ack must report both slots: % X", body)
}
for index := 0; index < 2; index++ {
want, _ := MailboxAttachmentClaimObject(1, index)
offset := 5 + index*12
if binary.LittleEndian.Uint32(body[offset:offset+4]) != 1 || binary.LittleEndian.Uint32(body[offset+4:offset+8]) != want {
t.Fatalf("ack row %d = % X, want letter 1 slot object %d", index, body[offset:offset+12], want)
}
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if mail := box.Mails["1"]; !mail.Claimed || len(mail.Attachments) != 0 {
t.Fatalf("a whole-letter claim must close the letter: %+v", mail)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
delivered := 0
for _, stack := range inventory.Slots {
if stack.ItemID == 2001 || stack.ItemID == 2002 {
delivered += int(stack.Count)
}
}
if delivered != 2 {
t.Fatalf("both attachments must be delivered, got %d", delivered)
}
}
// TestMailboxSnapshotRendersALetterPastTheAddressableSlots keeps the other
// "mailbox will not open" shape closed: a letter carrying more attachments than
// a claim object can address must still produce a page. The slots past the
// sixteenth fall back to the letter id and merge into its first slot instead of
// failing the whole snapshot.
func TestMailboxSnapshotRendersALetterPastTheAddressableSlots(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
attachments := make([]dnfrepo.MailAttachment, 0, mailboxMaxAttachmentsPerMail+4)
for index := 0; index < cap(attachments); index++ {
attachments = append(attachments, dnfrepo.MailAttachment{ItemID: int64(2000 + index), Count: 1})
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
CreatedAt: now,
Attachments: attachments,
}}, 0, now)
if err != nil {
t.Fatalf("an unaddressable slot must never fail the whole page: %v", err)
}
if int(body[0]) != len(attachments) {
t.Fatalf("summary count = %d, want %d", body[0], len(attachments))
}
var letterID [4]byte
binary.LittleEndian.PutUint32(letterID[:], 52)
if !bytes.Contains(body, letterID[:]) {
t.Fatal("the fallback rows must address the letter itself")
}
}
// TestMailboxSnapshotSkipsEmptiedSlots is the "mailbox will not open"
// regression: a letter carrying an already-emptied slot must still render.
func TestMailboxSnapshotSkipsEmptiedSlots(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
mail := dnfrepo.MailRecord{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{
{},
{ItemID: 44, Count: 1},
{ItemID: 0, Count: 5},
},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatalf("an emptied slot must never fail the whole page: %v", err)
}
if body[0] != 1 {
t.Fatalf("summary count = %d, want only the one remaining attachment", body[0])
}
// The surviving row keeps the claim object of its own position, so a slot the
// client already holds never changes address while its neighbours are emptied.
want, err := MailboxAttachmentClaimObject(52, 1)
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(body[2:6]); got != want {
t.Fatalf("leading dword = %d, want slot 1's claim object %d", got, want)
}
}
@@ -0,0 +1,199 @@
package mail
import (
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 这组回归锁定"一个容器里同一道具 id 的总量不得超过 PVF [stack limit]":
// 邮件领取只能并进现有那一行,容器里已经有该道具的行时不许再开第二行;
// 没写 [stack limit] 的道具不受这条约束。
func mailClaimStackLimitResolution(start, end, stackLimit int64) alignedcmd.MailboxItemResolution {
return alignedcmd.MailboxItemResolution{
Kind: "stackable",
PVFPath: "stackable/material/claim_limit.stk",
StackableType: "[material]",
SlotStart: int16(start),
SlotEnd: int16(end),
StackLimit: stackLimit,
PreferQuickSlots: false,
}
}
// 容器里已经有这道具的行、而且还有额度:必须并进那一行,不能另开一行。
func TestAddAttachmentMergesIntoExistingRowUnderContainerStackLimit(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: 1001, Count: 3, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 2},
mailClaimStackLimitResolution(65, 120, 1000))
if err != nil {
t.Fatalf("addAttachmentToInventory error = %v", err)
}
if len(changed) != 1 || changed[0] != 70 {
t.Fatalf("changed slots = %v, want the existing row 70", changed)
}
if len(inventory.Slots) != 1 {
t.Fatalf("claim opened a second row: %+v", inventory.Slots)
}
if stack := inventory.Slots["0:70"]; stack.Count != 5 {
t.Fatalf("merged stack = %+v, want count 5", stack)
}
}
// [stack limit] 1:容器里已经有一件时,第二件既不能另开一行也不能写进去,
// 按本路径既有规则整封拒绝(ErrMailboxInventoryFull)。
func TestAddAttachmentRefusesSecondRowForStackLimitOne(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: 1001, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 1},
mailClaimStackLimitResolution(65, 120, 1))
if !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("error = %v, want ErrMailboxInventoryFull", err)
}
if changed != nil {
t.Fatalf("changed slots = %v, want nil", changed)
}
if len(inventory.Slots) != 1 || inventory.Slots["0:70"].Count != 1 {
t.Fatalf("refused claim mutated the container: %+v", inventory.Slots)
}
}
// 容器是空的但 [stack limit] 1 只允许一件:落点最多写一件,剩下的余量由本路径
// 既有规则报 ErrMailboxInventoryFull 交给调用方(领取事务会丢弃这份候选,信留原样)。
func TestAddAttachmentCapsAmountAtContainerStackLimit(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 2},
mailClaimStackLimitResolution(65, 120, 1))
if !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("error = %v, want ErrMailboxInventoryFull", err)
}
if changed != nil {
t.Fatalf("changed slots = %v, want nil on the refused remainder", changed)
}
if len(inventory.Slots) != 1 || inventory.Slots["0:65"].Count != 1 {
t.Fatalf("capped claim wrote container=%+v, want one row with exactly one unit", inventory.Slots)
}
}
// 同一个上限在领取路径上的对外行为:两件 [stack limit] 1 的道具永远拿不到,
// 整封拒绝(ack 4),容器里一行都不留,信保留。
func TestMailboxExtractRefusesAttachmentAboveContainerStackLimit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 2}}},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
Repositories: repos,
MailboxItemResolver: func(uint32) (alignedcmd.MailboxItemResolution, error) {
return mailClaimStackLimitResolution(121, 176, 1), nil
},
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
box, _, _ := repos.Mailbox.Load(ctx, "77")
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("capped claim must retain the attachment: %+v", box.Mails["1"])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if len(inventory.Slots) != 0 {
t.Fatalf("capped claim wrote rows: %+v", inventory.Slots)
}
}
// 没写 [stack limit] 的道具沿用旧行为:容器里那一行不在本次落点范围内时,
// 仍然可以在范围内开新行(有 [stack limit] 的道具此时必须拒绝)。
func TestAddAttachmentKeepsUncappedAndCappedBehaviorApart(t *testing.T) {
uncapped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 5, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&uncapped, dnfrepo.MailAttachment{ItemID: 1001, Count: 3},
mailClaimStackLimitResolution(65, 120, 0))
if err != nil {
t.Fatalf("uncapped addAttachmentToInventory error = %v", err)
}
if len(changed) != 1 || changed[0] != 65 {
t.Fatalf("uncapped changed slots = %v, want a fresh row at 65", changed)
}
if stack := uncapped.Slots["0:65"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("uncapped fresh row = %+v", stack)
}
if stack := uncapped.Slots["0:9"]; stack.Count != 5 {
t.Fatalf("uncapped existing row was mutated: %+v", stack)
}
capped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 5, Extra: map[string]string{"item_kind": "stackable"}},
}}
if _, err := addAttachmentToInventory(&capped, dnfrepo.MailAttachment{ItemID: 1001, Count: 3},
mailClaimStackLimitResolution(65, 120, 1000)); !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("capped error = %v, want ErrMailboxInventoryFull", err)
}
if len(capped.Slots) != 1 {
t.Fatalf("capped claim opened a second row: %+v", capped.Slots)
}
}
// 领取路径的对外行为:[stack limit] 1 且容器里已有一件时整封拒绝(ack 4),
// 信件和附件都保留,容器不变。
func TestMailboxExtractRetainsLetterWhenStackLimitOneContainerIsFull(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 2001, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
}}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 1}}},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
Repositories: repos,
MailboxItemResolver: func(uint32) (alignedcmd.MailboxItemResolution, error) {
return mailClaimStackLimitResolution(121, 176, 1), nil
},
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
box, _, _ := repos.Mailbox.Load(ctx, "77")
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("stack-limit rejection must retain the attachment: %+v", box.Mails["1"])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if len(inventory.Slots) != 1 || inventory.Slots["0:121"].Count != 1 {
t.Fatalf("rejected claim mutated the container: %+v", inventory.Slots)
}
}
@@ -0,0 +1,302 @@
package mail
import (
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 领取邮件附件不能落进角色还没解锁的背包格:活体 2026-09-15 出现过附件被塞进
// 未购买的行里,玩家在客户端连点都点不到。
//
// 窗口口径与 inventory.moveDestinationUnlockedSlotRange / dnfbridge.
// currentMainInventoryUnlockedEnd 完全一致:主背包标准分类 7 行 × 8 格 = 56 格,
// 前 32 格随角色解锁,之后按 CharacterContainerState.MainSlotCount(0/8/16/24) 逐行开放,
// 所以"最后一个已解锁格" = PVF 范围起点 + 32 - 1 + MainSlotCount,上界再 +1。
func TestClaimUnlockedMainSlotsWindowMatchesInventoryOwner(t *testing.T) {
ctx := context.Background()
// 材料分类 65..120:65 + 32 - 1 + MainSlotCount 是最后一格,上界为 +1。
material := alignedcmd.MailboxItemResolution{
Kind: "stackable", StackableType: "[material]",
SlotStart: 65, SlotEnd: 120, StackLimit: 1,
}
cases := []struct {
name string
mainSlotCount string
want int16
why string
}{
{"no expansion", "0", 97, "默认 4 行 ⇒ 最后一格 96"},
{"one row bought", "8", 105, "5 行 ⇒ 最后一格 104"},
{"two rows bought", "16", 113, "6 行 ⇒ 最后一格 112"},
{"three rows bought", "24", 121, "7 行 ⇒ 最后一格 120(整个 PVF 范围)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
owner, _ := newMailClaimSlotTestOwner(t, ctx, tc.mainSlotCount)
if got := owner.claimUnlockedMainSlots(ctx, "77", material); got != tc.want {
t.Fatalf("main_list_param16=%s: bound=%d want=%d (%s)", tc.mainSlotCount, got, tc.want, tc.why)
}
})
}
}
// fail-open:读不到容器状态、或该道具页不按行解锁(快捷栏 3..8、宠物/装扮/守护石页、
// 固定材料 345..359 等)时必须返回 0(= 不设界、保持旧行为)。算错窗口会挡掉全部领取,
// 比原 bug 更糟。
func TestClaimUnlockedMainSlotsFailsOpen(t *testing.T) {
ctx := context.Background()
owner, _ := newMailClaimSlotTestOwner(t, ctx, "0")
material := alignedcmd.MailboxItemResolution{
Kind: "stackable", StackableType: "[material]",
SlotStart: 65, SlotEnd: 120, StackLimit: 1,
}
if got := owner.claimUnlockedMainSlots(ctx, "", material); got != 0 {
t.Fatalf("空角色 id 必须 fail-open,实际 %d", got)
}
// 没有容器状态行的角色:不能凭空假设 4 行(那是"发明状态")。
bare := NewOwner(dnfrepomemory.NewMemoryGroup(), nil)
if got := bare.claimUnlockedMainSlots(ctx, "78", material); got != 0 {
t.Fatalf("无容器状态行必须 fail-open,实际 %d", got)
}
// 非 56 格区段一律不设界:快捷栏、固定材料页、宠物页、装扮页、守护石页。
otherPages := []alignedcmd.MailboxItemResolution{
{Kind: "stackable", SlotStart: 3, SlotEnd: 8, StackLimit: 1, PreferQuickSlots: true},
{Kind: "stackable", SlotStart: 345, SlotEnd: 359, StackLimit: 1},
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 7},
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 1},
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 38},
// 账号共享晶体/灵魂槽(354..365)属于账号侧 durable owner,不按角色行走。
{Kind: "stackable", SlotStart: 354, SlotEnd: 365, StackLimit: 1},
}
for _, resolution := range otherPages {
if got := owner.claimUnlockedMainSlots(ctx, "77", resolution); got != 0 {
t.Fatalf("list=%d range=%d..%d 必须 fail-open,实际 %d",
resolution.ListType, resolution.SlotStart, resolution.SlotEnd, got)
}
}
}
// addAttachmentToInventoryBounded 的第二个参数是**排他上界**(最后一格 + 1):
// 未解锁格既不能被占用、也不能算作可用空间。
func TestAddAttachmentToInventoryRespectsUnlockedMainSlots(t *testing.T) {
// 合成夹具,不依赖 PVF:主背包合法区段是 3..23,上界 8 表示"只有 3..7 已解锁"。
resolution := alignedcmd.MailboxItemResolution{
Kind: "stackable",
PVFPath: "stackable/material/unlocked_slot.stk",
StackableType: "[material]",
SlotStart: 3,
SlotEnd: 23,
StackLimit: 1,
PreferQuickSlots: false,
}
attachment := dnfrepo.MailAttachment{ItemID: 700, Count: 1}
t.Run("只落在已解锁行内", func(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
changed, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 8)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(changed) != 1 || changed[0] < 3 || changed[0] >= 8 {
t.Fatalf("落到未解锁格: changed=%v", changed)
}
})
t.Run("已解锁格占满时报背包已满而不是塞进未解锁格", func(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
for slot := int16(3); slot < 8; slot++ {
inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
dnfrepo.ItemStack{ItemID: 900, Count: 1}
}
if _, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 8); !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("err=%v want ErrMailboxInventoryFull", err)
}
for key, stack := range inventory.Slots {
if stack.ItemID == 700 {
t.Fatalf("附件仍被放进 %s", key)
}
}
})
t.Run("未知解锁数时保持旧行为", func(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
for slot := int16(3); slot < 8; slot++ {
inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
dnfrepo.ItemStack{ItemID: 900, Count: 1}
}
changed, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 0)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(changed) != 1 || changed[0] < 8 {
t.Fatalf("未知解锁数时应允许更后面的格子: changed=%v", changed)
}
})
}
// 端到端:材料页 65..120,角色只买了 1 行扩展(main_list_param16=8,即第 5 行
// 97..104 已解锁)。把已解锁的 65..96 填满后领取,附件必须落在 97 而不是任何地方都行;
// 同一份背包在"0 行扩展"(只解锁 65..96)时必须报背包已满并保留附件。
func TestMailboxExtractPlacesAttachmentInsideUnlockedRowsOnly(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
mainSlotCount string
wantFull bool
wantSlot int16
}{
{name: "one row bought places in slot 97", mainSlotCount: "8", wantSlot: 97},
{name: "no expansion reports the bag full", mainSlotCount: "0", wantFull: true},
} {
t.Run(tc.name, func(t *testing.T) {
_, repos := newMailClaimSlotTestOwner(t, ctx, tc.mainSlotCount)
slots := make(map[string]dnfrepo.ItemStack)
for slot := int16(65); slot <= 96; slot++ {
slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
dnfrepo.ItemStack{ItemID: 9000 + int64(slot), Count: 1}
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots}); err != nil {
t.Fatalf("save occupied unlocked rows: %v", err)
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 1}}},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
Repositories: repos,
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 1),
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if tc.wantFull {
// ackType 0x005F code 4:附件放不进去,客户端据此自己画"背包已满"。
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("领取失败必须保留附件: %+v", box.Mails["1"])
}
return
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if stack, ok := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, tc.wantSlot)]; !ok || stack.ItemID != 2001 {
t.Fatalf("附件没有落在已解锁的 %d 格: key=%v stack=%+v", tc.wantSlot, ok, stack)
}
for slot := int16(105); slot <= 120; slot++ {
if _, occupied := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)]; occupied {
t.Fatalf("附件落进了未解锁的第 %d 格(只买了 1 行扩展)", slot)
}
}
if !box.Mails["1"].Claimed {
t.Fatalf("领取成功应结清整封信: %+v", box.Mails["1"])
}
})
}
}
// 附件并进一行**锁定**的同类堆时必须被拒绝:装备锁的语义是不可交易/强化/分解/丢弃,
// 把锁定件数量悄悄加一等于绕过了锁。错误必须能被上层识别成"锁定",而不是"背包已满"
// (后者会让玩家去清空背包,但问题根本不在空间)。
func TestMailboxExtractRejectsAttachmentsMergingIntoALockedRow(t *testing.T) {
ctx := context.Background()
_, repos := newMailClaimSlotTestOwner(t, ctx, "0")
locked := dnfrepo.ItemStack{
ItemID: 2001, Count: 1,
Extra: map[string]string{"equipment_lock_state": "1"},
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65): locked,
},
}); err != nil {
t.Fatalf("save locked row: %v", err)
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 1}}},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
resolution, err := mailboxClaimTestResolver(65, 120, 1)(2001)
if err != nil {
t.Fatalf("resolve attachment: %v", err)
}
if _, err := addAttachmentToInventoryBounded(
&dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65): locked,
}},
dnfrepo.MailAttachment{ItemID: 2001, Count: 1},
resolution,
97,
); !errors.Is(err, ErrMailboxItemLocked) {
t.Fatalf("err=%v want ErrMailboxItemLocked", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
Repositories: repos,
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 1),
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
// ackType 0x005F 表里没有"锁定"这一格时用 4:客户端会自己跑一次背包检查并画提示。
// 这里断言"确实回了失败码、而且不是 0",码值本身由 mailboxClaimErrorCode 决定。
body := got.UpperResponses[0].Body
if len(body) != 2 || body[0] != 0x00 || body[1] == 0 {
t.Fatalf("锁定领取必须回非零失败码,实际 % X", body)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("锁定被拒必须保留附件: %+v", box.Mails["1"])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if stack := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65)]; stack.Count != 1 {
t.Fatalf("锁定堆的数量被改成了 %d", stack.Count)
}
}
// newMailClaimSlotTestOwner 建一个带容器状态行的角色 77:main_list_param16 就是
// CharacterContainerState.MainSlotCount(0/8/16/24)。
func newMailClaimSlotTestOwner(t *testing.T, ctx context.Context, mainSlotCount string) (Owner, dnfrepo.Group) {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: dnfrepo.CharacterContainerStateScope("77"),
Values: map[string]string{
"main_list_param16": mainSlotCount,
"avatar_list_param16": "0",
"personal_cargo_list_param16": "8",
},
}); err != nil {
t.Fatalf("save container state (main_list_param16=%s): %v", mainSlotCount, err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
return NewOwner(repos, nil), repos
}
+2
View File
@@ -0,0 +1,2 @@
// Package mail 承接 DNF 邮箱打开、附件领取和邮件状态类协议。
package mail
+296
View File
@@ -0,0 +1,296 @@
package mail
import (
"context"
"fmt"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type handler struct{}
// NewHandler creates the mailbox protocol handler.
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain returns the mailbox business domain.
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainMail
}
// Handle routes the mailbox command family using the current NoPack.exe
// readers/writers as the packet authority.
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
opcode := dnfenum.CmdPacket(req.Opcode)
switch opcode {
case dnfenum.CmdPacketMailboxOpen:
return handleMailboxOpen(ctx, req)
case dnfenum.CmdPacketMailboxSend:
return handleMailboxSend(ctx, req)
case dnfenum.CmdPacketMailboxExtractItem:
return handleMailboxExtract(ctx, req)
case dnfenum.CmdPacketChangeLetterStat:
return handleMailboxChangeState(ctx, req)
case dnfenum.CmdPacketMultiMailboxSend:
return handleMailboxMultiSend(ctx, req)
case dnfenum.CmdPacketQueryCharacInfoMailbox:
return handleMailboxQueryCharacter(ctx, req)
case dnfenum.CmdPacketRequestServerCharacterList:
return handleMailboxRecipientCharacterList(ctx, req)
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("mail module has no route for opcode %d", req.Opcode),
}, nil
}
}
func handleMailboxRecipientCharacterList(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
parsed, err := decodeRecipientCharacterListRequest(req.Body)
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: "mailbox_recipient_list_rejected",
Reason: err.Error(),
}, nil
}
if req.Repositories.Character == nil {
return alignedcmd.Result{}, ErrMailboxCharacterMissing
}
accountID := strings.TrimSpace(req.AccountID)
if accountID == "" {
return alignedcmd.Result{}, ErrMailboxCharacterMissing
}
characters, err := req.Repositories.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
if err != nil {
return alignedcmd.Result{}, err
}
body, err := buildRecipientCharacterListBody(parsed.ServerID, characters, req.SelectedCharacterID)
if err != nil {
return alignedcmd.Result{}, err
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: "mailbox_recipient_list",
Reason: fmt.Sprintf("current op789 server=%d returned %d other account roles through op718", parsed.ServerID, body[1]),
UpperResponses: []alignedcmd.UpperResponse{
class0Response(mailboxRecipientListMessageID, body),
},
}, nil
}
func handleMailboxOpen(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
if err := decodeMailboxOpenRequest(req.Body); err != nil {
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
if err != nil {
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
result, err := NewOwner(req.Repositories).Open(ctx, characterID)
if err != nil {
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
snapshot, err := buildMailboxListNotification(result.Mails, result.NotLoaded, result.ObservedAt)
if err != nil {
return class1Result(req.Opcode, "mailbox_open_rejected", "mailbox snapshot unavailable: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
out := class1Result(
req.Opcode,
"mailbox_open",
fmt.Sprintf("current op96 ACK not_loaded=%d total=%d unread=%d claimable=%d", result.NotLoaded, result.Total, result.Unread, result.Claimable),
buildMailboxOpenAck(result.NotLoaded),
)
out.UpperResponses = append(out.UpperResponses, class0Response(MailboxListNotificationMessageID, snapshot))
return out, nil
}
func handleMailboxSend(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
return handleMailboxSendOpcode(ctx, req, dnfenum.CmdPacketMailboxSend)
}
func handleMailboxSendOpcode(ctx context.Context, req alignedcmd.Request, opcode dnfenum.CmdPacket) (alignedcmd.Result, error) {
parsed, err := decodeSendRequest(opcode, req.Body)
if err != nil {
return class1Result(req.Opcode, "mailbox_send_rejected", "invalid current mailbox send body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
if err != nil {
return class1Result(req.Opcode, "mailbox_send_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
result, err := NewOwner(req.Repositories, req.MailboxItemResolver).Send(ctx, characterID, parsed)
if err != nil {
return class1Result(req.Opcode, "mailbox_send_rejected", "mailbox send rejected: "+err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxErrorCode(err))), nil
}
out := class1Result(
req.Opcode,
"mailbox_send_ack",
fmt.Sprintf("mailbox send committed mail=%s recipient=%s; request=%s", result.MailID, result.RecipientCharacterID, parsed.String()),
buildSuccessAck(),
)
if recipientID, parseErr := strconv.ParseUint(result.RecipientCharacterID, 10, 16); parseErr == nil && recipientID != 0 {
out.MailboxAlarmRecipientID = uint16(recipientID)
}
return out, nil
}
func handleMailboxExtract(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
parsed, err := decodeExtractRequest(req.Body)
if err != nil {
return class1Result(req.Opcode, "mailbox_extract_rejected", "invalid current mailbox extract body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
if err != nil {
return class1Result(req.Opcode, "mailbox_extract_rejected", err.Error(), buildErrorAck(mailboxClaimErrorCode(err))), nil
}
owner := NewOwner(req.Repositories, req.MailboxItemResolver).
WithPremiumContracts(req.PremiumContractResolver, req.AccountID)
result, err := owner.Claim(ctx, characterID, parsed)
if err != nil {
return class1Result(req.Opcode, "mailbox_extract_rejected", "mailbox extract rejected: "+err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxClaimErrorCode(err))), nil
}
out := class1Result(
req.Opcode,
"mailbox_extract_ack",
fmt.Sprintf("claimed mails=%v rows=%v gold=%d items=%d premium=%t", result.MailIDs, result.Claimed, result.Gold, result.ItemCount, result.PremiumActivated),
buildExtractAck(result.Claimed),
)
// A claimed 契约 extends the account entitlement inside the claim transaction,
// but the client keeps drawing its old remaining time until it is told. This
// is the same class0 notification the inventory and cera-shop activation paths
// send; without it the mailed contract looked like it granted nothing at all.
for _, activation := range result.PremiumActivations {
if !premium.CanNotifyActivation(activation.PremiumType) {
continue
}
out.UpperResponses = append(out.UpperResponses, class0Response(
premium.ActivatedNotificationMessageID,
premium.BuildActivatedBody(activation.PremiumType, activation.RemainingSeconds),
))
if activation.PremiumType == premium.TypeCrystal {
// The crystal contract also drives a separate bridge-owned state block.
out.PostActions = append(out.PostActions, alignedcmd.PostActionRefreshCrystalContractState)
}
}
// Current NoPack keeps the selected mail object alive until the op95 handler
// finishes. A clear-first op97 page here destroys that object and crashes the
// client after the durable claim has already committed. Preserve the captured
// native order: op95 ACK -> exact op14 rows -> class0/op98 remove notification.
out.ItemSlotRefreshes = append(out.ItemSlotRefreshes, result.ItemSlotRefreshes...)
out.EpicBookBalances = append(out.EpicBookBalances, result.EpicBookBalances...)
if result.Gold > 0 {
goldAfter := result.GoldAfter
out.CharacterGoldAfter = &goldAfter
}
out.PostItemSlotRefreshResponses = append(out.PostItemSlotRefreshResponses, class0Response(
mailboxRemoveNotificationMessageID,
buildMailboxRemoveNotification(result.MailIDs),
))
if result.Wallet != nil {
out.CeraWallet = &alignedcmd.CeraWalletProjection{
Cera: result.Wallet.Cera,
TokenCera: result.Wallet.TokenCera,
HappyTokenCera: result.Wallet.HappyTokenCera,
}
}
return out, nil
}
func handleMailboxChangeState(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
parsed, err := decodeChangeStateRequest(req.Body)
if err != nil {
return class1Result(req.Opcode, "mailbox_state_rejected", "invalid current mailbox state body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
if err != nil {
return class1Result(req.Opcode, "mailbox_state_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
if err := NewOwner(req.Repositories).ChangeState(ctx, characterID, parsed); err != nil {
return class1Result(req.Opcode, "mailbox_state_rejected", err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxErrorCode(err))), nil
}
out := class1Result(
req.Opcode,
"mailbox_state_ack",
"mailbox state committed; request="+parsed.String(),
buildChangeStateAck(parsed.MailIDs, parsed.Status),
)
return out, nil
}
func handleMailboxMultiSend(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
return handleMailboxSendOpcode(ctx, req, dnfenum.CmdPacketMultiMailboxSend)
}
func handleMailboxQueryCharacter(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
parsed, err := decodeQueryCharacterRequest(req.Body)
if err != nil {
return class1Result(req.Opcode, "mailbox_query_rejected", "invalid current mailbox query body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
if req.Repositories.Character == nil {
return class1Result(req.Opcode, "mailbox_query_rejected", ErrMailboxCharacterMissing.Error(), buildErrorAck(mailboxErrorCode(ErrMailboxCharacterMissing))), nil
}
characterID, found, err := req.Repositories.Character.FindIDByName(ctx, parsed.Name)
if err != nil {
return class1Result(req.Opcode, "mailbox_query_rejected", "query character failed: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
if !found {
return class1Result(req.Opcode, "mailbox_query_rejected", "recipient not found: "+parsed.Name, buildErrorAck(mailboxErrorCode(ErrMailboxRecipientMissing))), nil
}
character, found, err := req.Repositories.Character.Load(ctx, characterID)
if err != nil || !found {
if err == nil {
err = ErrMailboxRecipientMissing
}
return class1Result(req.Opcode, "mailbox_query_rejected", "load queried character: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
}
job, grow, level, err := mailboxCharacterProjection(character)
if err != nil {
return class1Result(
req.Opcode,
"mailbox_query_rejected",
"queried character projection is unavailable: "+err.Error(),
buildErrorAck(mailboxErrorCode(err)),
), nil
}
return class1Result(
req.Opcode,
"mailbox_query_ack",
fmt.Sprintf("current op324 character=%s job=%d grow=%d level=%d mode=%d", characterID, job, grow, level, parsed.Mode),
buildQueryCharacterAck(parsed.NameRaw, grow, level, job),
), nil
}
func mailboxCharacterProjection(character dnfrepo.CharacterRecord) (byte, byte, uint16, error) {
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil {
return 0, 0, 0, fmt.Errorf("%w: invalid job %q", ErrMailboxInvalidRequest, character.Job)
}
growValue, found := character.Stats["grow_type"]
if !found || growValue < 0 || growValue > 0xff {
return 0, 0, 0, fmt.Errorf("%w: invalid grow_type", ErrMailboxInvalidRequest)
}
if character.Level <= 0 || character.Level > 0xffff {
return 0, 0, 0, fmt.Errorf("%w: invalid level %d", ErrMailboxInvalidRequest, character.Level)
}
return byte(jobValue), byte(growValue), uint16(character.Level), nil
}
func class1Result(opcode uint16, operation string, reason string, body []byte) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: reason,
UpperResponses: []alignedcmd.UpperResponse{class1Response(opcode, body)},
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
package mail
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerClearAllRemovesEveryMailboxRow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Now().UTC()
if err := repositories.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "88",
Mails: map[string]dnfrepo.MailRecord{
"1": {
MailID: "1",
Gold: 120,
Attachments: []dnfrepo.MailAttachment{{ItemID: 5001, Count: 1}},
CreatedAt: now,
},
"2": {
MailID: "2",
Read: true,
Claimed: true,
CreatedAt: now,
},
"3": {
MailID: "3",
Deleted: true,
CreatedAt: now,
},
},
UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
result, err := NewOwner(repositories).ClearAll(ctx, "88")
if err != nil {
t.Fatalf("ClearAll() error = %v", err)
}
if result.Removed != 3 || result.Unread != 1 || result.Claimable != 1 || result.Gold != 120 || result.Attachments != 1 {
t.Fatalf("ClearAll() = %+v", result)
}
record, found, err := repositories.Mailbox.Load(ctx, "88")
if err != nil || !found || len(record.Mails) != 0 {
t.Fatalf("mailbox after clear found=%t error=%v record=%+v", found, err, record)
}
}
@@ -0,0 +1,241 @@
package mail
import (
"context"
"strconv"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 本文件钉住 Open() 的**分页窗口取哪一头**。
//
// 背景(实机 2026-09-23,角色 5「随便玩玩」):451 封活着的邮件、窗口恒为
// mail_id 516..535,GM 新发的 966 落库成功却永远进不了窗口,玩家侧的表现就是
// "收不到邮件"。原因见 owner.go Open() 里的注释:窗口只发一页、协议没有翻页,
// 而老实现 normal[:20] 取的是**最老**的 20 封,于是窗口被最老那批永久钉死。
//
// 这些用例不测"顺序"(发出去的仍是升序,客户端自己重排),只测**选中的集合**。
// pagedMailboxFixture 造一个只依赖邮箱仓库的角色:normalCount 封普通邮件后接
// savedCount 封"保存的"邮件,id 从 1 递增,created_at 每封 +1 秒 —— 所以
// **id 越大越新**,窗口该取哪一头一眼可辨。
type pagedMailboxFixture struct {
repos dnfrepo.Group
characterID string
}
func newPagedMailboxFixture(t *testing.T, characterID string, normalCount, savedCount int) pagedMailboxFixture {
t.Helper()
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
// 放在 1 小时前,确保既不过期、也不落在未来。
base := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
mails := make(map[string]dnfrepo.MailRecord, normalCount+savedCount)
for i := 1; i <= normalCount+savedCount; i++ {
id := strconv.Itoa(i)
record := dnfrepo.MailRecord{
MailID: id,
SenderName: "系统",
Title: "分页测试邮件",
Gold: int64(i),
CreatedAt: base.Add(time.Duration(i) * time.Second),
Attachments: []dnfrepo.MailAttachment{{ItemID: 1001, Count: 1}},
}
if i > normalCount {
record.Metadata = map[string]string{"mailbox_saved": "true"}
}
mails[id] = record
}
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: characterID,
Mails: mails,
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
return pagedMailboxFixture{
repos: repos,
characterID: characterID,
}
}
// appendNewest 追加一封严格最新的普通邮件,返回它的 mail_id。
func (f pagedMailboxFixture) appendNewest(t *testing.T) string {
t.Helper()
ctx := context.Background()
record, found, err := f.repos.Mailbox.Load(ctx, f.characterID)
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
newest := 0
for id := range record.Mails {
if n, convErr := strconv.Atoi(id); convErr == nil && n > newest {
newest = n
}
}
id := strconv.Itoa(newest + 1)
record.Mails[id] = dnfrepo.MailRecord{
MailID: id,
SenderName: "系统",
Title: "分页测试邮件",
CreatedAt: time.Now().UTC(),
Attachments: []dnfrepo.MailAttachment{{ItemID: 1001, Count: 1}},
}
record.UpdatedAt = time.Now().UTC()
if err := f.repos.Mailbox.Save(ctx, record); err != nil {
t.Fatalf("save mailbox: %v", err)
}
return id
}
func (f pagedMailboxFixture) open(t *testing.T) OpenResult {
t.Helper()
result, err := NewOwner(f.repos).Open(context.Background(), f.characterID)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
return result
}
func mailboxMailIDs(mails []dnfrepo.MailRecord) []string {
out := make([]string, 0, len(mails))
for _, mail := range mails {
out = append(out, mail.MailID)
}
return out
}
func idRange(from, to int) []string {
out := make([]string, 0, to-from+1)
for i := from; i <= to; i++ {
out = append(out, strconv.Itoa(i))
}
return out
}
func assertMailIDs(t *testing.T, label string, got []string, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s = %v (len %d), want %v (len %d)", label, got, len(got), want, len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s = %v, want %v (first mismatch at index %d)", label, got, want, i)
}
}
}
// 超过一页时,留下的必须是**最新**的 20 封;窗口内仍保持升序,字节布局不变。
func TestMailboxOpenPageKeepsNewestNormalMails(t *testing.T) {
fixture := newPagedMailboxFixture(t, "77", 25, 0)
opened := fixture.open(t)
assertMailIDs(t, "page", mailboxMailIDs(opened.Mails), idRange(6, 25))
if opened.NotLoaded != 5 {
t.Fatalf("NotLoaded = %d, want 5", opened.NotLoaded)
}
// 全量统计不能跟着分页一起缩水。
if opened.Total != 25 || opened.Unread != 25 || opened.Claimable != 25 {
t.Fatalf("total=%d unread=%d claimable=%d, want 25/25/25", opened.Total, opened.Unread, opened.Claimable)
}
}
// 保存的邮件同理:留最新的 10 封。
func TestMailboxOpenPageKeepsNewestSavedMails(t *testing.T) {
fixture := newPagedMailboxFixture(t, "78", 0, 12)
opened := fixture.open(t)
assertMailIDs(t, "page", mailboxMailIDs(opened.Mails), idRange(3, 12))
if opened.NotLoaded != 2 {
t.Fatalf("NotLoaded = %d, want 2", opened.NotLoaded)
}
if opened.Total != 12 {
t.Fatalf("Total = %d, want 12", opened.Total)
}
}
// 普通页与保存页各有各的窗口,合起来顺序不变:先是普通页,再是保存页。
func TestMailboxOpenPageKeepsNormalAndSavedWindowsSeparate(t *testing.T) {
fixture := newPagedMailboxFixture(t, "79", 25, 12)
opened := fixture.open(t)
want := append(idRange(6, 25), idRange(28, 37)...)
assertMailIDs(t, "page", mailboxMailIDs(opened.Mails), want)
if opened.NotLoaded != 7 {
t.Fatalf("NotLoaded = %d, want 7 (5 normal + 2 saved)", opened.NotLoaded)
}
if opened.Total != 37 {
t.Fatalf("Total = %d, want 37", opened.Total)
}
}
// 回归:这是实机那次事故的形状。积压远超一页时,**新来的那封信必须看得见**。
// 老实现下窗口恒为最老的 20 封,新信永远进不来 —— 玩家看到的就是"收不到邮件"。
func TestMailboxOpenKeepsNewLetterVisibleBehindALargeBacklog(t *testing.T) {
const backlog = 450
fixture := newPagedMailboxFixture(t, "80", backlog, 0)
before := fixture.open(t)
assertMailIDs(t, "backlog page", mailboxMailIDs(before.Mails), idRange(backlog-19, backlog))
if before.NotLoaded != backlog-20 {
t.Fatalf("NotLoaded = %d, want %d", before.NotLoaded, backlog-20)
}
freshID := fixture.appendNewest(t)
after := fixture.open(t)
found := false
for _, id := range mailboxMailIDs(after.Mails) {
if id == freshID {
found = true
break
}
}
if !found {
t.Fatalf("新邮件 %s 不在窗口里:page=%v", freshID, mailboxMailIDs(after.Mails))
}
// 窗口滑动:最老的那封被挤出去,长度不变。
if len(after.Mails) != mailboxNormalPageSize {
t.Fatalf("page len = %d, want %d", len(after.Mails), mailboxNormalPageSize)
}
assertMailIDs(t, "slid page", mailboxMailIDs(after.Mails), idRange(backlog-18, backlog+1))
if after.NotLoaded != backlog-19 {
t.Fatalf("NotLoaded = %d, want %d", after.NotLoaded, backlog-19)
}
}
// 不到一页时逐封全给,且删除/过期的仍然不参与窗口,也不算进 NotLoaded。
func TestMailboxOpenPageUnderOnePageReturnsEveryLiveMail(t *testing.T) {
fixture := newPagedMailboxFixture(t, "81", 3, 0)
ctx := context.Background()
record, found, err := fixture.repos.Mailbox.Load(ctx, fixture.characterID)
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
deleted := record.Mails["1"]
deleted.Deleted = true
record.Mails["1"] = deleted
expired := record.Mails["2"]
expired.CreatedAt = time.Now().UTC().Add(-(mailboxNormalLifetime + time.Hour))
record.Mails["2"] = expired
if err := fixture.repos.Mailbox.Save(ctx, record); err != nil {
t.Fatalf("save mailbox: %v", err)
}
opened := fixture.open(t)
assertMailIDs(t, "page", mailboxMailIDs(opened.Mails), []string{"3"})
if opened.NotLoaded != 0 {
t.Fatalf("NotLoaded = %d, want 0", opened.NotLoaded)
}
if opened.Total != 1 {
t.Fatalf("Total = %d, want 1", opened.Total)
}
}
+364
View File
@@ -0,0 +1,364 @@
package mail
import (
"encoding/binary"
"fmt"
"strings"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
const (
maxMailboxRecipientBytes = 30
maxMailboxMessageBytes = 512
maxMailboxAttachments = 10
maxMailboxBatch = 100
)
// SendAttachment is the exact 12-byte attachment row written by current
// NoPack.exe sub_26FC590. ItemID and Count are validation values only; the
// owner must resolve the durable item from ListType and SlotIndex.
type SendAttachment struct {
ListType byte
SlotIndex uint16
ItemID uint32
Count uint32
}
// SendRequest is the exact current op94/op315 request body. Opcode 315 is the
// multi-attachment variant; it is not a multi-recipient command.
type SendRequest struct {
RecipientName string
RecipientRaw []byte
Gold uint32
Attachments []SendAttachment
Body string
BodyRaw []byte
Special uint32
Global bool
RawLen int
}
func (r SendRequest) String() string {
return fmt.Sprintf(
"recipient=%q body_len=%d gold=%d attachments=%d special=%d global=%t raw=%d",
r.RecipientName,
len(r.BodyRaw),
r.Gold,
len(r.Attachments),
r.Special,
r.Global,
r.RawLen,
)
}
// ExtractRequest is the exact current op95 request: u32 count followed by
// count u32 mail IDs.
type ExtractRequest struct {
MailIDs []uint32
RawLen int
}
func (r ExtractRequest) String() string {
return fmt.Sprintf("ids=%v raw=%d", r.MailIDs, r.RawLen)
}
// ChangeStateRequest is the exact current op134 request: u32 count, count u32
// mail IDs, then one u16 state.
type ChangeStateRequest struct {
MailIDs []uint32
Status uint16
RawLen int
}
func (r ChangeStateRequest) String() string {
return fmt.Sprintf("ids=%v status=%d raw=%d", r.MailIDs, r.Status, r.RawLen)
}
// QueryCharacterRequest is the exact current op324 request.
type QueryCharacterRequest struct {
Name string
NameRaw []byte
Mode byte
RawLen int
}
func (r QueryCharacterRequest) String() string {
return fmt.Sprintf("name=%q mode=%d raw=%d", r.Name, r.Mode, r.RawLen)
}
func decodeSendRequest(opcode dnfenum.CmdPacket, body []byte) (SendRequest, error) {
cursor := mailboxCursor{body: body}
recipientRaw, err := cursor.dstring(maxMailboxRecipientBytes)
if err != nil {
return SendRequest{}, fmt.Errorf("recipient: %w", err)
}
recipientName, err := decodeMailboxText(recipientRaw)
if err != nil || strings.TrimSpace(recipientName) == "" {
return SendRequest{}, fmt.Errorf("recipient: invalid client text")
}
gold, err := cursor.u32()
if err != nil {
return SendRequest{}, fmt.Errorf("gold: %w", err)
}
attachmentCount := 1
switch opcode {
case dnfenum.CmdPacketMailboxSend:
case dnfenum.CmdPacketMultiMailboxSend:
count, readErr := cursor.u8()
if readErr != nil {
return SendRequest{}, fmt.Errorf("attachment count: %w", readErr)
}
attachmentCount = int(count)
if attachmentCount > maxMailboxAttachments {
return SendRequest{}, fmt.Errorf("attachment count %d exceeds %d", attachmentCount, maxMailboxAttachments)
}
default:
return SendRequest{}, fmt.Errorf("unsupported mailbox send opcode %d", opcode)
}
attachments := make([]SendAttachment, 0, attachmentCount)
for idx := 0; idx < attachmentCount; idx++ {
listType, readErr := cursor.u8()
if readErr != nil {
return SendRequest{}, fmt.Errorf("attachment %d list type: %w", idx, readErr)
}
slot, readErr := cursor.u16()
if readErr != nil {
return SendRequest{}, fmt.Errorf("attachment %d slot: %w", idx, readErr)
}
itemID, readErr := cursor.u32()
if readErr != nil {
return SendRequest{}, fmt.Errorf("attachment %d item id: %w", idx, readErr)
}
count, readErr := cursor.u32()
if readErr != nil {
return SendRequest{}, fmt.Errorf("attachment %d count: %w", idx, readErr)
}
// op94 writes one all-zero row when the composer has no attachment.
if listType == 0 && slot == 0 && itemID == 0 && count == 0 {
continue
}
if itemID == 0 || count == 0 {
return SendRequest{}, fmt.Errorf("attachment %d has incomplete identity", idx)
}
attachments = append(attachments, SendAttachment{
ListType: listType,
SlotIndex: slot,
ItemID: itemID,
Count: count,
})
}
messageRaw, err := cursor.dstring(maxMailboxMessageBytes)
if err != nil {
return SendRequest{}, fmt.Errorf("message: %w", err)
}
message, err := decodeMailboxTextAllowEmpty(messageRaw)
if err != nil {
return SendRequest{}, fmt.Errorf("message: invalid client text")
}
special, err := cursor.u32()
if err != nil {
return SendRequest{}, fmt.Errorf("special: %w", err)
}
global, err := cursor.u8()
if err != nil {
return SendRequest{}, fmt.Errorf("global: %w", err)
}
if global > 1 {
return SendRequest{}, fmt.Errorf("global flag is %d", global)
}
if !cursor.done() {
return SendRequest{}, fmt.Errorf("trailing mailbox send bytes: %d", cursor.remaining())
}
return SendRequest{
RecipientName: recipientName,
RecipientRaw: append([]byte(nil), recipientRaw...),
Gold: gold,
Attachments: attachments,
Body: message,
BodyRaw: append([]byte(nil), messageRaw...),
Special: special,
Global: global == 1,
RawLen: len(body),
}, nil
}
func decodeExtractRequest(body []byte) (ExtractRequest, error) {
ids, cursor, err := decodeMailIDList(body)
if err != nil {
return ExtractRequest{}, err
}
if !cursor.done() {
return ExtractRequest{}, fmt.Errorf("trailing mailbox extract bytes: %d", cursor.remaining())
}
return ExtractRequest{MailIDs: ids, RawLen: len(body)}, nil
}
func decodeChangeStateRequest(body []byte) (ChangeStateRequest, error) {
ids, cursor, err := decodeMailIDList(body)
if err != nil {
return ChangeStateRequest{}, err
}
status, err := cursor.u16()
if err != nil {
return ChangeStateRequest{}, fmt.Errorf("mailbox state: %w", err)
}
if !cursor.done() {
return ChangeStateRequest{}, fmt.Errorf("trailing mailbox state bytes: %d", cursor.remaining())
}
return ChangeStateRequest{MailIDs: ids, Status: status, RawLen: len(body)}, nil
}
func decodeQueryCharacterRequest(body []byte) (QueryCharacterRequest, error) {
cursor := mailboxCursor{body: body}
nameRaw, err := cursor.dstring(maxMailboxRecipientBytes)
if err != nil {
return QueryCharacterRequest{}, fmt.Errorf("query name: %w", err)
}
name, err := decodeMailboxText(nameRaw)
if err != nil || strings.TrimSpace(name) == "" {
return QueryCharacterRequest{}, fmt.Errorf("query name: invalid client text")
}
mode, err := cursor.u8()
if err != nil {
return QueryCharacterRequest{}, fmt.Errorf("query mode: %w", err)
}
if !cursor.done() {
return QueryCharacterRequest{}, fmt.Errorf("trailing mailbox query bytes: %d", cursor.remaining())
}
return QueryCharacterRequest{
Name: name,
NameRaw: append([]byte(nil), nameRaw...),
Mode: mode,
RawLen: len(body),
}, nil
}
func decodeMailboxOpenRequest(body []byte) error {
if len(body) != 0 {
return fmt.Errorf("mailbox open body must be empty, got %d bytes", len(body))
}
return nil
}
type recipientCharacterListRequest struct {
ServerID byte
}
func decodeRecipientCharacterListRequest(body []byte) (recipientCharacterListRequest, error) {
if len(body) != 1 {
return recipientCharacterListRequest{}, fmt.Errorf("mailbox account-role request must contain one server id byte, got %d bytes", len(body))
}
return recipientCharacterListRequest{ServerID: body[0]}, nil
}
func decodeMailIDList(body []byte) ([]uint32, mailboxCursor, error) {
cursor := mailboxCursor{body: body}
count, err := cursor.u32()
if err != nil {
return nil, cursor, fmt.Errorf("mail id count: %w", err)
}
if count == 0 || count > maxMailboxBatch {
return nil, cursor, fmt.Errorf("mail id count %d is outside 1..%d", count, maxMailboxBatch)
}
if uint64(cursor.remaining()) < uint64(count)*4 {
return nil, cursor, fmt.Errorf("mail id list is truncated")
}
ids := make([]uint32, 0, count)
seen := make(map[uint32]struct{}, count)
for idx := uint32(0); idx < count; idx++ {
id, readErr := cursor.u32()
if readErr != nil {
return nil, cursor, fmt.Errorf("mail id %d: %w", idx, readErr)
}
if id == 0 {
return nil, cursor, fmt.Errorf("mail id %d is zero", idx)
}
if _, exists := seen[id]; exists {
return nil, cursor, fmt.Errorf("mail id %d is duplicated", id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, cursor, nil
}
func decodeMailboxText(raw []byte) (string, error) {
if len(raw) == 0 {
return "", fmt.Errorf("empty text")
}
return decodeMailboxTextAllowEmpty(raw)
}
func decodeMailboxTextAllowEmpty(raw []byte) (string, error) {
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes(raw)
if err != nil {
return "", err
}
if !utf8.Valid(decoded) {
return "", fmt.Errorf("decoded text is not utf8")
}
return string(decoded), nil
}
type mailboxCursor struct {
body []byte
pos int
}
func (c *mailboxCursor) u8() (byte, error) {
if c.remaining() < 1 {
return 0, fmt.Errorf("need 1 byte, have %d", c.remaining())
}
value := c.body[c.pos]
c.pos++
return value, nil
}
func (c *mailboxCursor) u16() (uint16, error) {
if c.remaining() < 2 {
return 0, fmt.Errorf("need 2 bytes, have %d", c.remaining())
}
value := binary.LittleEndian.Uint16(c.body[c.pos : c.pos+2])
c.pos += 2
return value, nil
}
func (c *mailboxCursor) u32() (uint32, error) {
if c.remaining() < 4 {
return 0, fmt.Errorf("need 4 bytes, have %d", c.remaining())
}
value := binary.LittleEndian.Uint32(c.body[c.pos : c.pos+4])
c.pos += 4
return value, nil
}
func (c *mailboxCursor) dstring(max int) ([]byte, error) {
length, err := c.u32()
if err != nil {
return nil, err
}
if length > uint32(max) {
return nil, fmt.Errorf("DSTR length %d exceeds %d", length, max)
}
if uint64(c.remaining()) < uint64(length) {
return nil, fmt.Errorf("DSTR length %d exceeds remaining %d", length, c.remaining())
}
start := c.pos
c.pos += int(length)
return c.body[start:c.pos], nil
}
func (c mailboxCursor) remaining() int {
return len(c.body) - c.pos
}
func (c mailboxCursor) done() bool {
return c.pos == len(c.body)
}
@@ -0,0 +1,131 @@
package mail
import (
"context"
"fmt"
"math"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// SystemSendRequest is a trusted server-side mailbox delivery. Unlike the
// player op94/op315 flow it has no sender postage or source inventory, but it
// still commits through the mailbox asset unit of work used by both MySQL and
// SQLite repositories.
type SystemSendRequest struct {
RecipientCharacterID string
RecipientName string
Title string
Body string
Gold int64
Attachments []dnfrepo.MailAttachment
Source string
CompletionKey string
}
type SystemSendResult struct {
MailID string
RecipientCharacterID string
RecipientName string
}
// SendSystem validates a trusted delivery and appends it atomically to the
// authoritative mailbox aggregate. Item definitions and wire rows must already
// have been resolved by the bridge from the active runtime PVF.
func (o Owner) SendSystem(ctx context.Context, req SystemSendRequest) (SystemSendResult, error) {
if o.repos.Character == nil {
return SystemSendResult{}, ErrMailboxCharacterMissing
}
if o.repos.Mailbox == nil {
return SystemSendResult{}, ErrMailboxRepoMissing
}
if o.repos.MailboxAssets == nil {
return SystemSendResult{}, ErrMailboxAssetsUOW
}
req.RecipientCharacterID = strings.TrimSpace(req.RecipientCharacterID)
req.RecipientName = strings.TrimSpace(req.RecipientName)
req.Title = strings.TrimSpace(req.Title)
req.Body = strings.TrimSpace(req.Body)
req.Source = strings.TrimSpace(req.Source)
if req.RecipientCharacterID == "" && req.RecipientName == "" {
return SystemSendResult{}, ErrMailboxRecipientMissing
}
if req.Source == "" || req.Gold < 0 || req.Gold > math.MaxUint32 || len(req.Attachments) > maxMailboxAttachments ||
(req.Gold == 0 && len(req.Attachments) == 0 && req.Title == "" && req.Body == "") {
return SystemSendResult{}, ErrMailboxInvalidRequest
}
if req.Title != "" {
if _, err := encodeMailboxText(req.Title, maxMailboxMessageBytes); err != nil {
return SystemSendResult{}, fmt.Errorf("%w: title: %v", ErrMailboxInvalidRequest, err)
}
}
if req.Body != "" {
if _, err := encodeMailboxText(req.Body, maxMailboxMessageBytes); err != nil {
return SystemSendResult{}, fmt.Errorf("%w: body: %v", ErrMailboxInvalidRequest, err)
}
}
for index, attachment := range req.Attachments {
if attachment.ItemID <= 0 || attachment.ItemID > math.MaxUint32 ||
attachment.Count <= 0 || attachment.Count > math.MaxUint32 {
return SystemSendResult{}, fmt.Errorf("%w: attachment=%d", ErrMailboxInvalidRequest, index)
}
}
recipientID := req.RecipientCharacterID
if recipientID == "" {
var found bool
var err error
recipientID, found, err = o.repos.Character.FindIDByName(ctx, req.RecipientName)
if err != nil {
return SystemSendResult{}, err
}
if !found {
return SystemSendResult{}, ErrMailboxRecipientMissing
}
}
var result SystemSendResult
err := o.repos.MailboxAssets.WithinMailboxAssets(ctx, recipientID, recipientID, func(
_ dnfrepo.AccountRepository,
_ dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
_ dnfrepo.InventoryRepository,
mailboxes dnfrepo.MailboxRepository,
) error {
recipient, found, err := characters.Load(ctx, recipientID)
if err != nil {
return err
}
if !found || recipient.Stats["delete_flag"] != 0 {
return ErrMailboxRecipientMissing
}
recipientName := strings.TrimSpace(recipient.Name)
if recipientName == "" || (req.RecipientName != "" && recipientName != req.RecipientName) {
return ErrMailboxRecipientMissing
}
mailID, err := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
RecipientCharacterID: recipientID,
RecipientName: recipientName,
Title: req.Title,
Body: req.Body,
Source: req.Source,
CompletionKey: strings.TrimSpace(req.CompletionKey),
Gold: req.Gold,
Attachments: req.Attachments,
})
if err != nil {
return err
}
result = SystemSendResult{
MailID: mailID,
RecipientCharacterID: recipientID,
RecipientName: recipientName,
}
return nil
})
if err != nil {
return SystemSendResult{}, err
}
return result, nil
}
@@ -0,0 +1,94 @@
package mail
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestSystemMailOwnerCommitsGoldAndAttachmentThroughMailboxUnitOfWork(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "7",
AccountID: "dnf:test",
Name: "Receiver",
}); err != nil {
t.Fatal(err)
}
result, err := NewOwner(repositories).SendSystem(ctx, SystemSendRequest{
RecipientName: "Receiver",
Title: "GM delivery",
Body: "Native mailbox transaction",
Gold: 1234,
Attachments: []dnfrepo.MailAttachment{{
ItemID: 3227,
Count: 5,
Bind: true,
}},
Source: "gm_admin_system_mail",
})
if err != nil {
t.Fatalf("SendSystem() error = %v", err)
}
if result.MailID != "1" || result.RecipientCharacterID != "7" || result.RecipientName != "Receiver" {
t.Fatalf("SendSystem() = %+v", result)
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "7")
if err != nil || !found {
t.Fatalf("load mailbox found=%t error=%v", found, err)
}
got := mailbox.Mails["1"]
if got.Gold != 1234 || got.SenderName != "系统" || got.RecipientName != "Receiver" ||
len(got.Attachments) != 1 || got.Attachments[0].ItemID != 3227 || got.Attachments[0].Count != 5 ||
got.Metadata["system_mail"] != "true" || got.Metadata["unlimited"] != "true" {
t.Fatalf("durable system mail = %+v", got)
}
}
func TestSystemMailOwnerRejectsMismatchedRecipientSelector(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", Name: "Receiver"}); err != nil {
t.Fatal(err)
}
_, err := NewOwner(repositories).SendSystem(ctx, SystemSendRequest{
RecipientCharacterID: "7",
RecipientName: "Different",
Body: "should fail",
Source: "gm_admin_system_mail",
})
if err == nil {
t.Fatal("SendSystem() accepted mismatched character id and name")
}
if _, found, loadErr := repositories.Mailbox.Load(ctx, "7"); loadErr != nil || found {
t.Fatalf("mismatched request changed mailbox found=%t error=%v", found, loadErr)
}
}
func TestSystemMailOwnerCompletionKeyReplaysOneAuctionPayoutMail(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", Name: "Receiver"}); err != nil {
t.Fatal(err)
}
request := SystemSendRequest{
RecipientCharacterID: "7", RecipientName: "Receiver", Title: "拍卖行成交款",
Gold: 150, Source: "auction_sale_proceeds", CompletionKey: "auction:payout:17:0",
}
first, err := NewOwner(repositories).SendSystem(ctx, request)
if err != nil {
t.Fatal(err)
}
second, err := NewOwner(repositories).SendSystem(ctx, request)
if err != nil {
t.Fatal(err)
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "7")
if err != nil || !found || first.MailID != second.MailID || len(mailbox.Mails) != 1 ||
mailbox.Mails[first.MailID].Metadata["completion_key"] != request.CompletionKey {
t.Fatalf("first=%+v second=%+v found=%t err=%v mailbox=%+v", first, second, found, err, mailbox)
}
}