DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package mail 承接 DNF 邮箱打开、附件领取和邮件状态类协议。
|
||||
package mail
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user