服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2030 lines
74 KiB
Go
2030 lines
74 KiB
Go
package mail
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"math"
|
|
"reflect"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
func TestMailboxOpenBuildsCurrentCountAck(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1"},
|
|
"2": {MailID: "2", Deleted: true},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxOpen),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if len(got.UpperResponses) != 2 {
|
|
t.Fatalf("response count = %d, want open ACK plus class0/0x61 snapshot", len(got.UpperResponses))
|
|
}
|
|
assertMailboxResponse(t, got.UpperResponses[0], uint16(dnfenum.CmdPacketMailboxOpen), dnfproto.DefaultChannelClassification, []byte{0x01, 0x00, 0x00})
|
|
wantSnapshot := []byte{0x01, 0x00}
|
|
wantSnapshot = appendU32(wantSnapshot, 1) // summary claim object / mail ID
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // sender character
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // sender DSTR
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // gold
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // item ID
|
|
wantSnapshot = append(wantSnapshot, 0) // has item
|
|
wantSnapshot = appendU32(wantSnapshot, 0)
|
|
wantSnapshot = append(wantSnapshot, 0, 0, 0) // u16 + u8
|
|
wantSnapshot = appendU32(wantSnapshot, 0)
|
|
wantSnapshot = append(wantSnapshot, 0, 0, 0, 0) // two u8 + u16
|
|
wantSnapshot = append(wantSnapshot, make([]byte, mailboxItemRawSize)...)
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // remaining seconds
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // text-only summary seed
|
|
wantSnapshot = append(wantSnapshot, 0) // mail type
|
|
wantSnapshot = append(wantSnapshot, 0, 0) // deferred page count
|
|
wantSnapshot = append(wantSnapshot, 1, 0) // one detail row
|
|
wantSnapshot = appendU32(wantSnapshot, 1)
|
|
wantSnapshot = appendU32(wantSnapshot, 0)
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // sender DSTR
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // body DSTR
|
|
wantSnapshot = appendU32(wantSnapshot, 0) // creation Unix time
|
|
wantSnapshot = append(wantSnapshot, 1, 0, 0)
|
|
assertMailboxResponse(t, got.UpperResponses[1], mailboxListNotificationMessageID, 0, wantSnapshot)
|
|
}
|
|
|
|
func TestMailboxRecipientCharacterListUsesOtherRolesFromSameAccount(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
for _, character := range []dnfrepo.CharacterRecord{
|
|
{CharacterID: "77", AccountID: "local-account", Name: "发送者", Slot: 0},
|
|
{CharacterID: "88", AccountID: "local-account", Name: "来来来", Slot: 1},
|
|
{CharacterID: "99", AccountID: "other-account", Name: "别的账号", Slot: 0},
|
|
} {
|
|
mustSaveCharacter(t, repos, character)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRequestServerCharacterList),
|
|
Body: []byte{3},
|
|
AccountID: "local-account",
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
nameRaw, err := encodeMailboxText("来来来", mailboxRecipientListNameMaxBytes)
|
|
if err != nil {
|
|
t.Fatalf("encode expected role name: %v", err)
|
|
}
|
|
want := []byte{3, 1}
|
|
want = appendU32(want, 88)
|
|
want = appendU32(want, uint32(len(nameRaw)))
|
|
want = append(want, nameRaw...)
|
|
want = append(want, 0)
|
|
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
|
t.Fatalf("mailbox account-role result = %+v, want one response", got)
|
|
}
|
|
assertMailboxResponse(t, got.UpperResponses[0], mailboxRecipientListMessageID, 0, want)
|
|
}
|
|
|
|
func TestMailboxRecipientCharacterListRejectsMalformedRequestWithoutReply(t *testing.T) {
|
|
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketRequestServerCharacterList),
|
|
Body: nil,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
|
t.Fatalf("malformed mailbox account-role result = %+v, want handled without a reply", got)
|
|
}
|
|
}
|
|
|
|
func TestMailboxSendTransfersGoldAndStoresRecipientMail(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "sender",
|
|
Stats: map[string]int64{"gold": 200},
|
|
})
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc-b", Name: "receiver", Slot: 1})
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxSend),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentSendBody(
|
|
"receiver",
|
|
50,
|
|
[]SendAttachment{{}},
|
|
"body",
|
|
0,
|
|
false,
|
|
false,
|
|
),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxSend, []byte{0x01})
|
|
if got.MailboxAlarmRecipientID != 88 {
|
|
t.Fatalf("online mailbox alarm recipient = %d, want 88", got.MailboxAlarmRecipientID)
|
|
}
|
|
|
|
sender, ok, err := repos.Character.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load sender ok=%v error=%v", ok, err)
|
|
}
|
|
if sender.Stats["gold"] != 48 {
|
|
t.Fatalf("sender gold = %d, want 48 after 50 transfer + 102 postage", sender.Stats["gold"])
|
|
}
|
|
box, ok, err := repos.Mailbox.Load(ctx, "88")
|
|
if err != nil || !ok {
|
|
t.Fatalf("recipient mailbox ok=%v error=%v", ok, err)
|
|
}
|
|
if len(box.Mails) != 1 {
|
|
t.Fatalf("mail count = %d, want 1: %+v", len(box.Mails), box.Mails)
|
|
}
|
|
for id, mail := range box.Mails {
|
|
if id != "1" || mail.MailID != "1" {
|
|
t.Fatalf("wire mail id = %q/%q, want uint32-compatible 1", id, mail.MailID)
|
|
}
|
|
if mail.SenderCharacterID != "77" || mail.RecipientCharacterID != "88" ||
|
|
mail.Title != "" || mail.Body != "body" || mail.Gold != 50 {
|
|
t.Fatalf("unexpected mail: %+v", mail)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMailboxSendAllowsDurablePVFItemWithZeroPrice(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: "local-account",
|
|
Name: "sender",
|
|
Stats: map[string]int64{"gold": 2000},
|
|
})
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "3",
|
|
AccountID: "local-account",
|
|
Name: "receiver",
|
|
Slot: 2,
|
|
})
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "1",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:125": {ItemID: 3166, Count: 3},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save sender inventory: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxSend),
|
|
SelectedCharacterID: 1,
|
|
Repositories: repos,
|
|
MailboxItemResolver: func(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
if itemID != 3166 {
|
|
t.Fatalf("resolver item = %d, want 3166", itemID)
|
|
}
|
|
return alignedcmd.MailboxItemResolution{
|
|
Price: 0,
|
|
PVFPath: "stackable/reproduced-item.stk",
|
|
Kind: "stackable",
|
|
SlotStart: 121,
|
|
SlotEnd: 176,
|
|
StackLimit: 1000,
|
|
}, nil
|
|
},
|
|
Body: currentSendBody("receiver", 0, []SendAttachment{{
|
|
ListType: 0,
|
|
SlotIndex: 125,
|
|
ItemID: 3166,
|
|
Count: 3,
|
|
}}, " ", 0, false, false),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxSend, []byte{0x01})
|
|
|
|
sender, found, err := repos.Character.Load(ctx, "1")
|
|
if err != nil || !found {
|
|
t.Fatalf("load sender found=%v err=%v", found, err)
|
|
}
|
|
if got, want := sender.Stats["gold"], int64(900); got != want {
|
|
t.Fatalf("sender gold = %d, want %d after fixed one-attachment postage", got, want)
|
|
}
|
|
inventory, found, err := repos.Inventory.Load(ctx, "1")
|
|
if err != nil || !found {
|
|
t.Fatalf("load sender inventory found=%v err=%v", found, err)
|
|
}
|
|
if _, exists := inventory.Slots["0:125"]; exists {
|
|
t.Fatalf("sent zero-value attachment remains in sender inventory: %+v", inventory.Slots["0:125"])
|
|
}
|
|
box, found, err := repos.Mailbox.Load(ctx, "3")
|
|
if err != nil || !found {
|
|
t.Fatalf("load recipient mailbox found=%v err=%v", found, err)
|
|
}
|
|
mail := box.Mails["1"]
|
|
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 3166 || mail.Attachments[0].Count != 3 {
|
|
t.Fatalf("recipient attachment = %+v, want item 3166 x3", mail.Attachments)
|
|
}
|
|
}
|
|
|
|
func TestMailboxSendThenRecipientOpenAndClaimCompletesSameAccountTransfer(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "local-account",
|
|
Name: "sender",
|
|
Stats: map[string]int64{"gold": 2000},
|
|
})
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "88",
|
|
AccountID: "local-account",
|
|
Name: "receiver",
|
|
Slot: 1,
|
|
Stats: map[string]int64{"gold": 10},
|
|
})
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:65": {ItemID: 1001, Count: 2, Bind: true},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save sender inventory: %v", err)
|
|
}
|
|
|
|
send, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMultiMailboxSend),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
MailboxItemResolver: func(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
if itemID != 1001 {
|
|
t.Fatalf("sender resolver item = %d, want 1001", itemID)
|
|
}
|
|
return alignedcmd.MailboxItemResolution{Price: 100, SlotStart: 65, SlotEnd: 120, StackLimit: 99}, nil
|
|
},
|
|
Body: currentSendBody("receiver", 50, []SendAttachment{{ListType: 0, SlotIndex: 65, ItemID: 1001, Count: 2}}, "same-account transfer", 0, false, true),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send mail: %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, send, dnfenum.CmdPacketMultiMailboxSend, []byte{0x01})
|
|
|
|
opened, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxOpen),
|
|
SelectedCharacterID: 88,
|
|
Repositories: repos,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("open recipient mailbox: %v", err)
|
|
}
|
|
if len(opened.UpperResponses) != 2 {
|
|
t.Fatalf("recipient open responses = %d, want ACK plus snapshot", len(opened.UpperResponses))
|
|
}
|
|
assertMailboxResponse(t, opened.UpperResponses[0], uint16(dnfenum.CmdPacketMailboxOpen), dnfproto.DefaultChannelClassification, []byte{0x01, 0x00, 0x00})
|
|
|
|
claim, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 88,
|
|
Repositories: repos,
|
|
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 99),
|
|
Body: currentMailIDList(1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("claim recipient mail: %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, claim, currentExtractAck(1))
|
|
|
|
receiver, found, err := repos.Character.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load receiver found=%v err=%v", found, err)
|
|
}
|
|
if got, want := receiver.Stats["gold"], int64(60); got != want {
|
|
t.Fatalf("receiver gold = %d, want %d", got, want)
|
|
}
|
|
inventory, found, err := repos.Inventory.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load recipient inventory found=%v err=%v", found, err)
|
|
}
|
|
if got := inventory.Slots["0:3"]; got.ItemID != 1001 || got.Count != 2 || !got.Bind {
|
|
t.Fatalf("recipient claimed item = %+v, want bound item 1001 x2", got)
|
|
}
|
|
mailbox, found, err := repos.Mailbox.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load recipient mailbox found=%v err=%v", found, err)
|
|
}
|
|
if mail := mailbox.Mails["1"]; !mail.Claimed || !mail.Read || mail.Gold != 0 || len(mail.Attachments) != 0 {
|
|
t.Fatalf("claimed mail did not clear durable assets: %+v", mail)
|
|
}
|
|
}
|
|
|
|
func TestMailboxMultiAttachmentSendUsesDurableSlots(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "sender",
|
|
Stats: map[string]int64{"gold": 5000},
|
|
})
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc-b", Name: "receiver"})
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:3": {ItemID: 1001, Count: 5, Bind: true},
|
|
"1:4": {ItemID: 2002, Count: 1, RawEntry: []byte{1, 2, 3}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
|
|
attachments := []SendAttachment{
|
|
{ListType: 0, SlotIndex: 3, ItemID: 1001, Count: 2},
|
|
{ListType: 1, SlotIndex: 4, ItemID: 2002, Count: 1},
|
|
}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMultiMailboxSend),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
MailboxItemResolver: func(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
return alignedcmd.MailboxItemResolution{Price: map[uint32]int64{1001: 100, 2002: 200}[itemID]}, nil
|
|
},
|
|
Body: currentSendBody("receiver", 0, attachments, "two", 0, false, true),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMultiMailboxSend, []byte{0x01})
|
|
|
|
inventory, ok, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load sender inventory ok=%v error=%v", ok, err)
|
|
}
|
|
if stack := inventory.Slots["0:3"]; stack.Count != 3 || stack.ItemID != 1001 {
|
|
t.Fatalf("remaining stack = %+v, want item 1001 x3", stack)
|
|
}
|
|
if _, exists := inventory.Slots["1:4"]; exists {
|
|
t.Fatalf("fully attached slot should be removed: %+v", inventory.Slots["1:4"])
|
|
}
|
|
sender, _, _ := repos.Character.Load(ctx, "77")
|
|
if sender.Stats["gold"] != 2880 {
|
|
t.Fatalf("sender gold = %d, want 2880 after 2120 attachment postage", sender.Stats["gold"])
|
|
}
|
|
box, ok, err := repos.Mailbox.Load(ctx, "88")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load recipient mailbox ok=%v error=%v", ok, err)
|
|
}
|
|
mail := box.Mails["1"]
|
|
if len(mail.Attachments) != 2 ||
|
|
mail.Attachments[0].ItemID != 1001 || mail.Attachments[0].Count != 2 ||
|
|
mail.Attachments[1].ItemID != 2002 || !bytes.Equal(mail.Attachments[1].RawEntry, []byte{1, 2, 3}) {
|
|
t.Fatalf("mail attachments = %+v", mail.Attachments)
|
|
}
|
|
}
|
|
|
|
func TestMailboxSendRejectsForgedAttachmentWithoutMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
Name: "sender",
|
|
Stats: map[string]int64{"gold": 100},
|
|
})
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "88", Name: "receiver"})
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "77",
|
|
Slots: map[string]dnfrepo.ItemStack{"0:3": {ItemID: 1001, Count: 2}},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxSend),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentSendBody(
|
|
"receiver",
|
|
50,
|
|
[]SendAttachment{{ListType: 0, SlotIndex: 3, ItemID: 9999, Count: 1}},
|
|
"",
|
|
0,
|
|
false,
|
|
false,
|
|
),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxSend, []byte{0x00, 0x07})
|
|
sender, _, _ := repos.Character.Load(ctx, "77")
|
|
if sender.Stats["gold"] != 100 {
|
|
t.Fatalf("rejected send changed gold to %d", sender.Stats["gold"])
|
|
}
|
|
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
|
if inventory.Slots["0:3"].Count != 2 {
|
|
t.Fatalf("rejected send changed inventory: %+v", inventory.Slots["0:3"])
|
|
}
|
|
if _, found, loadErr := repos.Mailbox.Load(ctx, "88"); loadErr != nil || found {
|
|
t.Fatalf("rejected send created mailbox found=%v err=%v", found, loadErr)
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractClaimsGoldAndItemAtomicallyThenRejectsDuplicate(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "receiver",
|
|
Stats: map[string]int64{"gold": 10},
|
|
})
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {
|
|
MailID: "123",
|
|
Gold: 50,
|
|
Attachments: []dnfrepo.MailAttachment{
|
|
{ItemID: 1001, Count: 2, Bind: true},
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
request := alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 99),
|
|
Body: currentMailIDList(123),
|
|
}
|
|
got, err := NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(123))
|
|
if got.CharacterGoldAfter == nil || *got.CharacterGoldAfter != 60 {
|
|
t.Fatalf("character gold projection = %v, want 60", got.CharacterGoldAfter)
|
|
}
|
|
if len(got.PostActions) != 0 {
|
|
t.Fatalf("post actions = %v, want no full container refresh", got.PostActions)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 1 || got.ItemSlotRefreshes[0] != (alignedcmd.ItemSlotRefresh{ListType: 0, SlotIndex: 3}) {
|
|
t.Fatalf("item slot refreshes = %+v, want claimed quick slot 0:3", got.ItemSlotRefreshes)
|
|
}
|
|
|
|
character, ok, err := repos.Character.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load character ok=%v error=%v", ok, err)
|
|
}
|
|
if character.Stats["gold"] != 60 {
|
|
t.Fatalf("gold = %d, want 60", character.Stats["gold"])
|
|
}
|
|
inventory, ok, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load inventory ok=%v error=%v", ok, err)
|
|
}
|
|
stack := inventory.Slots["0:3"]
|
|
if stack.ItemID != 1001 || stack.Count != 2 || !stack.Bind {
|
|
t.Fatalf("slot 0:3 = %+v, want claimed attachment", stack)
|
|
}
|
|
box, ok, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil || !ok {
|
|
t.Fatalf("load mailbox ok=%v error=%v", ok, err)
|
|
}
|
|
if !box.Mails["123"].Claimed || !box.Mails["123"].Read || box.Mails["123"].Gold != 0 || len(box.Mails["123"].Attachments) != 0 {
|
|
t.Fatalf("mail should be claimed/read: %+v", box.Mails["123"])
|
|
}
|
|
|
|
duplicate, err := NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("duplicate Handle error = %v", err)
|
|
}
|
|
// 86JP claim table (ackType 0x005F): a letter that no longer exists is 21.
|
|
assertOneMailboxResponse(t, duplicate, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x15})
|
|
character, _, _ = repos.Character.Load(ctx, "77")
|
|
inventory, _, _ = repos.Inventory.Load(ctx, "77")
|
|
if character.Stats["gold"] != 60 || inventory.Slots["0:3"].Count != 2 {
|
|
t.Fatalf("duplicate claim mutated assets: gold=%d stack=%+v", character.Stats["gold"], inventory.Slots["0:0"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractRoutesCrystalAndSoulAttachmentsToAccountInventory(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "receiver",
|
|
})
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {
|
|
MailID: "123",
|
|
Attachments: []dnfrepo.MailAttachment{
|
|
{ItemID: 3001, Count: 2},
|
|
{ItemID: 3002, Count: 3},
|
|
},
|
|
},
|
|
},
|
|
}); 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(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
switch itemID {
|
|
case 3001:
|
|
return alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
PVFPath: "stackable/material/crystal.stk",
|
|
SlotStart: dnfrepo.CrystalWarehouseFirstSlot,
|
|
SlotEnd: dnfrepo.CrystalWarehouseLastSlot,
|
|
StackLimit: 9999,
|
|
}, nil
|
|
case 3002:
|
|
return alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
PVFPath: "stackable/material/soul.stk",
|
|
SlotStart: dnfrepo.SoulWarehouseFirstSlot,
|
|
SlotEnd: dnfrepo.SoulWarehouseLastSlot,
|
|
StackLimit: 9999,
|
|
}, nil
|
|
default:
|
|
return alignedcmd.MailboxItemResolution{}, ErrMailboxAssetMismatch
|
|
}
|
|
},
|
|
Body: currentMailIDList(123),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
// Crystal and soul are two attachment slots, so the ACK reports both.
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAckSlots(123, 2))
|
|
wantRefreshes := []alignedcmd.ItemSlotRefresh{
|
|
{ListType: dnfrepo.MainInventoryListType, SlotIndex: dnfrepo.CrystalWarehouseFirstSlot},
|
|
{ListType: dnfrepo.MainInventoryListType, SlotIndex: dnfrepo.SoulWarehouseFirstSlot},
|
|
}
|
|
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
|
|
t.Fatalf("item slot refreshes = %+v, want %+v", got.ItemSlotRefreshes, wantRefreshes)
|
|
}
|
|
|
|
accountInventory, found, err := repos.AccountInventory.Load(ctx, "acc-a")
|
|
if err != nil || !found {
|
|
t.Fatalf("load account inventory found=%v err=%v", found, err)
|
|
}
|
|
if stack := accountInventory.Slots[dnfrepo.AccountSharedInventorySlotKey(dnfrepo.CrystalWarehouseFirstSlot)]; stack.ItemID != 3001 || stack.Count != 2 {
|
|
t.Fatalf("crystal warehouse stack = %+v, want item=3001 count=2", stack)
|
|
}
|
|
if stack := accountInventory.Slots[dnfrepo.AccountSharedInventorySlotKey(dnfrepo.SoulWarehouseFirstSlot)]; stack.ItemID != 3002 || stack.Count != 3 {
|
|
t.Fatalf("soul warehouse stack = %+v, want item=3002 count=3", stack)
|
|
}
|
|
if inventory, found, err := repos.Inventory.Load(ctx, "77"); err != nil || found || len(inventory.Slots) != 0 {
|
|
t.Fatalf("account-owned mail created character inventory found=%v err=%v inventory=%+v", found, err, inventory)
|
|
}
|
|
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil || !found || !mailbox.Mails["123"].Claimed || len(mailbox.Mails["123"].Attachments) != 0 {
|
|
t.Fatalf("mailbox claim found=%v err=%v mail=%+v", found, err, mailbox.Mails["123"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractCreditsEpicFragmentToAccountBookWithoutMaterialStack(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "receiver",
|
|
})
|
|
if err := repos.EpicBook.Save(ctx, dnfrepo.EpicBookRecord{
|
|
AccountID: "acc-a",
|
|
Balances: map[uint32]uint32{101020256: 10},
|
|
}); err != nil {
|
|
t.Fatalf("save epic book: %v", err)
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {
|
|
MailID: "123",
|
|
Attachments: []dnfrepo.MailAttachment{{
|
|
ItemID: 10300528,
|
|
Count: 7,
|
|
}},
|
|
},
|
|
},
|
|
}); 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(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
if itemID != 10300528 {
|
|
return alignedcmd.MailboxItemResolution{}, ErrMailboxAssetMismatch
|
|
}
|
|
return alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
PVFPath: "stackable/epic/fragment.stk",
|
|
StackableType: "[material]",
|
|
ListType: dnfrepo.MainInventoryListType,
|
|
SlotStart: 121,
|
|
SlotEnd: 126,
|
|
StackLimit: 9999,
|
|
EpicBookEquipmentID: 101020256,
|
|
EpicBookFragmentID: 10300528,
|
|
}, nil
|
|
},
|
|
Body: currentMailIDList(123),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(123))
|
|
if len(got.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("epic fragment emitted material-slot refreshes: %+v", got.ItemSlotRefreshes)
|
|
}
|
|
wantBalances := []alignedcmd.EpicBookBalanceProjection{{FragmentID: 10300528, Balance: 17}}
|
|
if !reflect.DeepEqual(got.EpicBookBalances, wantBalances) {
|
|
t.Fatalf("epic book projection = %+v, want %+v", got.EpicBookBalances, wantBalances)
|
|
}
|
|
book, found, err := repos.EpicBook.Load(ctx, "acc-a")
|
|
if err != nil || !found || book.Balances[101020256] != 17 {
|
|
t.Fatalf("epic book found=%v err=%v record=%+v", found, err, book)
|
|
}
|
|
if inventory, found, err := repos.Inventory.Load(ctx, "77"); err != nil || found || len(inventory.Slots) != 0 {
|
|
t.Fatalf("epic fragment entered material inventory found=%v err=%v inventory=%+v", found, err, inventory)
|
|
}
|
|
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil || !found || !mailbox.Mails["123"].Claimed || len(mailbox.Mails["123"].Attachments) != 0 {
|
|
t.Fatalf("mailbox claim found=%v err=%v mail=%+v", found, err, mailbox.Mails["123"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractUsesResolvedNativeContainerForEveryInventoryFamily(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
|
|
attachments := []dnfrepo.MailAttachment{
|
|
{ItemID: 6101, Count: 1}, // avatar
|
|
{ItemID: 6102, Count: 1}, // creature body
|
|
{ItemID: 6103, Count: 1}, // creature artifact
|
|
{ItemID: 6104, Count: 2}, // creature feed
|
|
{ItemID: 6105, Count: 1}, // guild medal
|
|
{ItemID: 6106, Count: 3}, // guardian gem
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {MailID: "123", Attachments: attachments},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
MailboxItemResolver: func(itemID uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
switch itemID {
|
|
case 6101:
|
|
return alignedcmd.MailboxItemResolution{Kind: "equipment", EquipmentType: "[skin avatar]", ListType: 1, SlotStart: 0, SlotEnd: 2, StackLimit: 1}, nil
|
|
case 6102:
|
|
return alignedcmd.MailboxItemResolution{Kind: "equipment", EquipmentType: "[creature]", ListType: 7, SlotStart: 0, SlotEnd: 2, StackLimit: 1}, nil
|
|
case 6103:
|
|
return alignedcmd.MailboxItemResolution{Kind: "equipment", EquipmentType: "[artifact red]", ListType: 7, SlotStart: 140, SlotEnd: 142, StackLimit: 1}, nil
|
|
case 6104:
|
|
return alignedcmd.MailboxItemResolution{Kind: "stackable", StackableType: "[feed]", ListType: 7, SlotStart: 189, SlotEnd: 191, StackLimit: 100}, nil
|
|
case 6105:
|
|
return alignedcmd.MailboxItemResolution{Kind: "equipment", EquipmentType: "[flag]", ListType: 38, SlotStart: 0, SlotEnd: 2, StackLimit: 1}, nil
|
|
case 6106:
|
|
return alignedcmd.MailboxItemResolution{Kind: "stackable", StackableType: "[flag gem]", ListType: 38, SlotStart: 49, SlotEnd: 51, StackLimit: 100}, nil
|
|
default:
|
|
return alignedcmd.MailboxItemResolution{}, ErrMailboxAssetMismatch
|
|
}
|
|
},
|
|
Body: currentMailIDList(123),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Six attachment slots, six ACK rows, each with its own claim object.
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAckSlots(123, len(attachments)))
|
|
wantRefreshes := []alignedcmd.ItemSlotRefresh{
|
|
{ListType: 1, SlotIndex: 0},
|
|
{ListType: 7, SlotIndex: 0},
|
|
{ListType: 7, SlotIndex: 140},
|
|
{ListType: 7, SlotIndex: 189},
|
|
{ListType: 38, SlotIndex: 0},
|
|
{ListType: 38, SlotIndex: 49},
|
|
}
|
|
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
|
|
t.Fatalf("refreshes=%+v want=%+v", got.ItemSlotRefreshes, wantRefreshes)
|
|
}
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("inventory found=%t err=%v", found, err)
|
|
}
|
|
for key, want := range map[string]int64{
|
|
"1:0": 6101, "7:0": 6102, "7:140": 6103, "7:189": 6104, "38:0": 6105, "38:49": 6106,
|
|
} {
|
|
if stack := inventory.Slots[key]; stack.ItemID != want {
|
|
t.Fatalf("slot %s=%+v want item=%d; all=%+v", key, stack, want, inventory.Slots)
|
|
}
|
|
}
|
|
for _, wrong := range []string{"0:9", "0:65", "0:121"} {
|
|
if _, exists := inventory.Slots[wrong]; exists {
|
|
t.Fatalf("native-container attachment leaked into main bag %s: %+v", wrong, inventory.Slots)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractRoutesReviveCoinRewardsToFixedWallet(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: 9001, Count: 4},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {MailID: "123", Attachments: []dnfrepo.MailAttachment{{ItemID: 42, Count: 6}}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentMailIDList(123),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(123))
|
|
if !reflect.DeepEqual(got.ItemSlotRefreshes, []alignedcmd.ItemSlotRefresh{{ListType: 0, SlotIndex: 1}}) {
|
|
t.Fatalf("refreshes=%+v", got.ItemSlotRefreshes)
|
|
}
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("inventory found=%t err=%v", found, err)
|
|
}
|
|
if wallet := inventory.Slots["0:1"]; wallet.ItemID != 1 || wallet.Count != 6 {
|
|
t.Fatalf("wallet=%+v inventory=%+v", wallet, inventory.Slots)
|
|
}
|
|
if material := inventory.Slots["0:121"]; material.ItemID != 9001 || material.Count != 4 {
|
|
t.Fatalf("unrelated material was overwritten: %+v", material)
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractHappyTokenCeraVoucherCreditsAccountWalletWithoutInventory(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "acc-a",
|
|
Metadata: map[string]string{
|
|
"account_cera": "700",
|
|
"account_token_cera": "100",
|
|
"account_happy_token_cera": "5",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save account: %v", err)
|
|
}
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
AccountID: "acc-a",
|
|
Name: "receiver",
|
|
})
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {
|
|
MailID: "123",
|
|
Attachments: []dnfrepo.MailAttachment{
|
|
{ItemID: HappyTokenCeraVoucherItemID, Count: 99999},
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentMailIDList(123),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(123))
|
|
if got.CeraWallet == nil || *got.CeraWallet != (alignedcmd.CeraWalletProjection{
|
|
Cera: 700, TokenCera: 100, HappyTokenCera: 100004,
|
|
}) {
|
|
t.Fatalf("wallet projection = %+v", got.CeraWallet)
|
|
}
|
|
if len(got.ItemSlotRefreshes) != 0 {
|
|
t.Fatalf("voucher emitted inventory refreshes: %+v", got.ItemSlotRefreshes)
|
|
}
|
|
account, found, err := repos.Account.Load(ctx, "acc-a")
|
|
if err != nil || !found || account.Metadata["account_happy_token_cera"] != "100004" {
|
|
t.Fatalf("account wallet found=%v err=%v metadata=%v", found, err, account.Metadata)
|
|
}
|
|
if inventory, found, err := repos.Inventory.Load(ctx, "77"); err != nil || found || len(inventory.Slots) != 0 {
|
|
t.Fatalf("voucher created material inventory found=%v err=%v inventory=%+v", found, err, inventory)
|
|
}
|
|
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil || !found || !mailbox.Mails["123"].Claimed || len(mailbox.Mails["123"].Attachments) != 0 {
|
|
t.Fatalf("mailbox claim found=%v err=%v mail=%+v", found, err, mailbox.Mails["123"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractUsesPVFItemTabWithInitialExpansionMarker(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
|
|
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
|
|
Scope: dnfrepo.CharacterContainerStateScope("77"),
|
|
Values: map[string]string{
|
|
"main_list_param16": "0",
|
|
"avatar_list_param16": "0",
|
|
"personal_cargo_list_param16": "8",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save initial expansion marker: %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: 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 alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
PVFPath: "stackable/cash/old_booster_selection.stk",
|
|
StackableType: "[booster selection]",
|
|
SlotStart: 121,
|
|
SlotEnd: 176,
|
|
StackLimit: 99,
|
|
PreferQuickSlots: false,
|
|
}, nil
|
|
},
|
|
Body: currentMailIDList(1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(1))
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%v err=%v", found, err)
|
|
}
|
|
if stack := inventory.Slots["0:121"]; stack.ItemID != 2001 || stack.Count != 2 {
|
|
t.Fatalf("material claim = %+v, want item 2001 x2 in material slot 0:121", stack)
|
|
}
|
|
stack := inventory.Slots["0:121"]
|
|
if stack.Extra["item_kind"] != "stackable" ||
|
|
stack.Extra["pvf_path"] != "stackable/cash/old_booster_selection.stk" ||
|
|
stack.Extra["stackable_type"] != "[booster selection]" ||
|
|
stack.Extra["stack_limit"] != "99" {
|
|
t.Fatalf("mail claim extension metadata = %+v, want runtime-PVF authority", stack.Extra)
|
|
}
|
|
if _, exists := inventory.Slots["0:3"]; exists {
|
|
t.Fatalf("automatic mail material claim entered quick slot: %+v", inventory.Slots)
|
|
}
|
|
box, _, _ := repos.Mailbox.Load(ctx, "77")
|
|
if !box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 0 {
|
|
t.Fatalf("successful claim should mark only after the material-tab insert: %+v", box.Mails["1"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractAddsRequiredEquipmentExtensionMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
|
|
raw := make([]byte, mailboxItemRawSize)
|
|
binary.LittleEndian.PutUint32(raw[0x06:0x0A], 0x1fd58e8e)
|
|
raw[0x0A] = 31
|
|
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], 31)
|
|
raw[0x13] = 1
|
|
binary.LittleEndian.PutUint16(raw[0x14:0x16], 7)
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 5001, Count: 1, RawEntry: raw}}},
|
|
},
|
|
}); 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 alignedcmd.MailboxItemResolution{
|
|
Kind: "equipment",
|
|
PVFPath: "equipment/weapon/test.equ",
|
|
EquipmentType: "[weapon]",
|
|
Durability: 57,
|
|
SlotStart: 9,
|
|
SlotEnd: 64,
|
|
StackLimit: 1,
|
|
}, nil
|
|
},
|
|
Body: currentMailIDList(1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(1))
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%v err=%v", found, err)
|
|
}
|
|
stack := inventory.Slots["0:9"]
|
|
if stack.ItemID != 5001 || stack.Count != 1 || stack.Extra["item_kind"] != "equipment" ||
|
|
stack.Extra["pvf_path"] != "equipment/weapon/test.equ" ||
|
|
stack.Extra["equipment_type"] != "[weapon]" ||
|
|
stack.Extra["durability"] != "31" || stack.Extra["max_durability"] != "57" {
|
|
t.Fatalf("equipment mail extension metadata = %+v", stack)
|
|
}
|
|
if len(stack.RawEntry) != mailboxItemRawSize || binary.LittleEndian.Uint32(stack.RawEntry[0x06:0x0A]) != 0x1fd58e8e ||
|
|
stack.RawEntry[0x0A]&0x1f != 31 || stack.RawEntry[0x13] != 1 || binary.LittleEndian.Uint16(stack.RawEntry[0x14:0x16]) != 7 {
|
|
t.Fatalf("equipment mail claim raw = %x", stack.RawEntry)
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractKeepsAttachmentsWhenTheirPVFTabIsFull(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver", Stats: map[string]int64{"gold": 10}})
|
|
slots := make(map[string]dnfrepo.ItemStack)
|
|
for slot := int16(3); slot <= 8; slot++ {
|
|
slots["0:"+strconv.Itoa(int(slot))] = dnfrepo.ItemStack{ItemID: 8000 + int64(slot), Count: 1}
|
|
}
|
|
for slot := int16(65); slot <= 120; slot++ {
|
|
slots["0:"+strconv.Itoa(int(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 full consumable tab: %v", err)
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1", Gold: 5, 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, 99),
|
|
Body: currentMailIDList(1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
// 86JP claim table: an attachment that cannot be inserted is code 4, after
|
|
// which the client runs its own inventory check and draws the no-space
|
|
// message. The former 6 was outside that table, so the button looked dead.
|
|
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("full-tab rejection must retain the attachment: %+v", box.Mails["1"])
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 10 {
|
|
t.Fatalf("full-tab rejection must roll back gold, got %d", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractFullMaterialTabDoesNotSpillIntoEmptyQuickSlots(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver", Stats: map[string]int64{"gold": 10}})
|
|
slots := make(map[string]dnfrepo.ItemStack)
|
|
for slot := int16(121); slot <= 176; slot++ {
|
|
slots["0:"+strconv.Itoa(int(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 full material tab: %v", err)
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1", Gold: 5, 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 alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
SlotStart: 121,
|
|
SlotEnd: 176,
|
|
StackLimit: 99,
|
|
PreferQuickSlots: false,
|
|
}, nil
|
|
},
|
|
Body: currentMailIDList(1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
// 86JP claim table: an attachment that cannot be inserted is code 4, after
|
|
// which the client runs its own inventory check and draws the no-space
|
|
// message. The former 6 was outside that table, so the button looked dead.
|
|
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("full material-tab rejection must retain the attachment: %+v", box.Mails["1"])
|
|
}
|
|
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
|
for slot := int16(3); slot <= 8; slot++ {
|
|
if _, exists := inventory.Slots["0:"+strconv.Itoa(int(slot))]; exists {
|
|
t.Fatalf("full material tab spilled into quick slot %d: %+v", slot, inventory.Slots)
|
|
}
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 10 {
|
|
t.Fatalf("full material-tab rejection must roll back gold, got %d", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractBatchFillsQuickSlotsAndItemTabBeforeLeavingFullMail(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver", Stats: map[string]int64{"gold": 0}})
|
|
mails := make(map[string]dnfrepo.MailRecord)
|
|
for id := uint32(1); id <= 8; id++ {
|
|
mails[strconv.FormatUint(uint64(id), 10)] = dnfrepo.MailRecord{
|
|
MailID: strconv.FormatUint(uint64(id), 10),
|
|
Gold: 1,
|
|
Attachments: []dnfrepo.MailAttachment{{
|
|
ItemID: int64(2000 + id),
|
|
Count: 99,
|
|
}},
|
|
}
|
|
}
|
|
mails["9"] = dnfrepo.MailRecord{
|
|
MailID: "9",
|
|
Gold: 9,
|
|
Attachments: []dnfrepo.MailAttachment{{
|
|
ItemID: 2009,
|
|
Count: 1,
|
|
}},
|
|
}
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{CharacterID: "77", Mails: mails}); 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, 66, 99),
|
|
Body: currentMailIDList(1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(1, 2, 3, 4, 5, 6, 7, 8))
|
|
if len(got.PostActions) != 0 {
|
|
t.Fatalf("post actions = %v, want no full container refresh", got.PostActions)
|
|
}
|
|
wantRefreshes := []alignedcmd.ItemSlotRefresh{
|
|
{ListType: 0, SlotIndex: 3}, {ListType: 0, SlotIndex: 4}, {ListType: 0, SlotIndex: 5},
|
|
{ListType: 0, SlotIndex: 6}, {ListType: 0, SlotIndex: 7}, {ListType: 0, SlotIndex: 8},
|
|
{ListType: 0, SlotIndex: 65}, {ListType: 0, SlotIndex: 66},
|
|
}
|
|
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
|
|
t.Fatalf("item slot refreshes = %+v, want %+v", got.ItemSlotRefreshes, wantRefreshes)
|
|
}
|
|
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%v err=%v", found, err)
|
|
}
|
|
for index, slot := range []int16{3, 4, 5, 6, 7, 8, 65, 66} {
|
|
stack := inventory.Slots["0:"+strconv.Itoa(int(slot))]
|
|
if stack.ItemID != int64(2001+index) || stack.Count != 99 {
|
|
t.Fatalf("slot 0:%d = %+v, want item=%d count=99", slot, stack, 2001+index)
|
|
}
|
|
}
|
|
box, _, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil {
|
|
t.Fatalf("load mailbox: %v", err)
|
|
}
|
|
for id := 1; id <= 8; id++ {
|
|
mail := box.Mails[strconv.Itoa(id)]
|
|
if !mail.Claimed || mail.Gold != 0 || len(mail.Attachments) != 0 {
|
|
t.Fatalf("mail %d should be committed: %+v", id, mail)
|
|
}
|
|
}
|
|
if mail := box.Mails["9"]; mail.Claimed || mail.Gold != 9 || len(mail.Attachments) != 1 {
|
|
t.Fatalf("full mail should remain claimable: %+v", mail)
|
|
}
|
|
character, _, err := repos.Character.Load(ctx, "77")
|
|
if err != nil || character.Stats["gold"] != 8 {
|
|
t.Fatalf("gold = %d err=%v, want only the eight committed letters", character.Stats["gold"], err)
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractBatchSkipsAlreadyClaimedLetters(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", Claimed: true, Read: true, Attachments: []dnfrepo.MailAttachment{{ItemID: 1001, Count: 1}}},
|
|
"2": {MailID: "2", Attachments: []dnfrepo.MailAttachment{{ItemID: 1002, 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, 99),
|
|
Body: currentMailIDList(1, 2),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertMailboxExtractSuccessWithIncrementalRemove(t, got, currentExtractAck(2))
|
|
inventory, found, err := repos.Inventory.Load(ctx, "77")
|
|
if err != nil || !found {
|
|
t.Fatalf("load inventory found=%v err=%v", found, err)
|
|
}
|
|
if stack := inventory.Slots["0:3"]; stack.ItemID != 1002 || stack.Count != 1 {
|
|
t.Fatalf("pending claim stack = %+v", stack)
|
|
}
|
|
box, _, _ := repos.Mailbox.Load(ctx, "77")
|
|
if !box.Mails["1"].Claimed || !box.Mails["2"].Claimed || len(box.Mails["2"].Attachments) != 0 {
|
|
t.Fatalf("claim state = %+v", box.Mails)
|
|
}
|
|
}
|
|
|
|
func TestMailboxPresentationTranslatesExistingOverflowMailWithoutMutatingIt(t *testing.T) {
|
|
durable := dnfrepo.MailRecord{
|
|
SenderName: "System",
|
|
Title: "Inventory full: box reward",
|
|
Body: "Your box reward was delivered here because the inventory is full.",
|
|
Metadata: map[string]string{"source": "magic_box_reward_inventory_full"},
|
|
}
|
|
presented := mailboxPresentationMail(durable)
|
|
if presented.SenderName != "系统" || presented.Title != "背包已满:礼盒奖励" ||
|
|
presented.Body != "背包空间不足,礼盒奖励已通过邮件发送。请清理对应道具分页后领取。" {
|
|
t.Fatalf("presented overflow mail = %+v", presented)
|
|
}
|
|
if durable.SenderName != "System" || durable.Title != "Inventory full: box reward" ||
|
|
durable.Body != "Your box reward was delivered here because the inventory is full." {
|
|
t.Fatalf("presentation must not rewrite durable mail: %+v", durable)
|
|
}
|
|
}
|
|
|
|
func TestMailboxOpenRepairsAssetsLeftOnAnAlreadyClaimedLetter(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1", Claimed: true, Read: true, Gold: 7, Attachments: []dnfrepo.MailAttachment{{ItemID: 1001, Count: 1}}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
opened, err := NewOwner(repos).Open(ctx, "77")
|
|
if err != nil {
|
|
t.Fatalf("open mailbox: %v", err)
|
|
}
|
|
if len(opened.Mails) != 1 || opened.Mails[0].Gold != 0 || len(opened.Mails[0].Attachments) != 0 {
|
|
t.Fatalf("opened claimed letter still projects assets: %+v", opened.Mails)
|
|
}
|
|
box, _, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil || box.Mails["1"].Gold != 0 || len(box.Mails["1"].Attachments) != 0 {
|
|
t.Fatalf("legacy claimed letter was not repaired: mailbox=%+v err=%v", box, err)
|
|
}
|
|
}
|
|
|
|
func TestMailboxConcurrentExtractCommitsExactlyOnce(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "77",
|
|
Name: "receiver",
|
|
Stats: map[string]int64{"gold": 10},
|
|
})
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {MailID: "123", Gold: 50},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
request := alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentMailIDList(123),
|
|
}
|
|
|
|
var wait sync.WaitGroup
|
|
results := make(chan alignedcmd.Result, 2)
|
|
errors := make(chan error, 2)
|
|
for range 2 {
|
|
wait.Add(1)
|
|
go func() {
|
|
defer wait.Done()
|
|
result, err := NewHandler().Handle(ctx, request)
|
|
results <- result
|
|
errors <- err
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
close(results)
|
|
close(errors)
|
|
for err := range errors {
|
|
if err != nil {
|
|
t.Fatalf("concurrent Handle error = %v", err)
|
|
}
|
|
}
|
|
successes := 0
|
|
alreadyClaimed := 0
|
|
for result := range results {
|
|
body := result.UpperResponses[0].Body
|
|
switch {
|
|
case bytes.Equal(body, currentExtractAckWholeLetter(123)):
|
|
successes++
|
|
case bytes.Equal(body, []byte{0x00, 0x15}):
|
|
// 86JP claim table: an already-claimed letter no longer exists.
|
|
alreadyClaimed++
|
|
default:
|
|
t.Fatalf("unexpected concurrent extract body: % X", body)
|
|
}
|
|
}
|
|
if successes != 1 || alreadyClaimed != 1 {
|
|
t.Fatalf("successes=%d already_claimed=%d, want 1/1", successes, alreadyClaimed)
|
|
}
|
|
character, _, _ := repos.Character.Load(ctx, "77")
|
|
if character.Stats["gold"] != 60 {
|
|
t.Fatalf("concurrent extract gold = %d, want exactly one grant to 60", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxExtractRejectsMissingMailID(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{CharacterID: "77"}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentMailIDList(999),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
// 86JP claim table: no such mail is code 21.
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x15})
|
|
if len(got.PostActions) != 0 {
|
|
t.Fatalf("post actions = %v, want none on rejection", got.PostActions)
|
|
}
|
|
}
|
|
|
|
func TestMailboxChangeStateUsesCurrentOp134ResultLayout(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{
|
|
"123": {MailID: "123", Claimed: true},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketChangeLetterStat),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
Body: currentStateBody(0, 123),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketChangeLetterStat, currentStateAck(0, 123))
|
|
box, _, _ := repos.Mailbox.Load(ctx, "77")
|
|
if !box.Mails["123"].Deleted {
|
|
t.Fatalf("mail state was not committed: %+v", box.Mails["123"])
|
|
}
|
|
}
|
|
|
|
func TestMailboxChangeStateMarksReadAndSavesCurrentLetterStates(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"123": {MailID: "123", CreatedAt: time.Now().UTC()},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
|
|
request := alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketChangeLetterStat),
|
|
SelectedCharacterID: 77,
|
|
Repositories: repos,
|
|
}
|
|
request.Body = currentStateBody(2, 123)
|
|
got, err := NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("mark read: %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketChangeLetterStat, currentStateAck(2, 123))
|
|
|
|
request.Body = currentStateBody(3, 123)
|
|
got, err = NewHandler().Handle(ctx, request)
|
|
if err != nil {
|
|
t.Fatalf("save mail: %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketChangeLetterStat, currentStateAck(3, 123))
|
|
box, _, err := repos.Mailbox.Load(ctx, "77")
|
|
if err != nil {
|
|
t.Fatalf("load mailbox: %v", err)
|
|
}
|
|
mail := box.Mails["123"]
|
|
if !mail.Read || !mailboxMailSaved(mail) || mailboxLetterStat(mail) != 3 {
|
|
t.Fatalf("mail state = %+v, want read/saved/stat3", mail)
|
|
}
|
|
}
|
|
|
|
func TestMailboxOpenExpiresNormalMailButKeepsSavedMail(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "77",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {MailID: "1", CreatedAt: created},
|
|
"2": {MailID: "2", CreatedAt: created, Metadata: map[string]string{"mailbox_saved": "true"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save mailbox: %v", err)
|
|
}
|
|
owner := NewOwner(repos)
|
|
owner.now = func() time.Time { return created.Add(mailboxNormalLifetime + time.Second) }
|
|
result, err := owner.Open(ctx, "77")
|
|
if err != nil {
|
|
t.Fatalf("open mailbox: %v", err)
|
|
}
|
|
if result.Total != 1 || len(result.Mails) != 1 || result.Mails[0].MailID != "2" || mailboxLetterStat(result.Mails[0]) != 3 {
|
|
t.Fatalf("open result = %+v, want only saved mail", result)
|
|
}
|
|
}
|
|
|
|
func TestMailboxAlarmNotificationUsesExactOneWordBody(t *testing.T) {
|
|
if got := BuildAlarmNotification(513); !bytes.Equal(got, []byte{0x01, 0x02}) {
|
|
t.Fatalf("class0/0x63 body = % X, want 01 02", got)
|
|
}
|
|
}
|
|
|
|
func TestUnlimitedSystemMailDoesNotFallBackToFifteenDayExpiry(t *testing.T) {
|
|
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
mail := dnfrepo.MailRecord{
|
|
CreatedAt: created,
|
|
Metadata: map[string]string{
|
|
"system_mail": "true",
|
|
"unlimited": "true",
|
|
},
|
|
}
|
|
now := created.Add(365 * 24 * time.Hour)
|
|
if mailExpired(mail, now) {
|
|
t.Fatal("unlimited system mail expired through the ordinary 15-day fallback")
|
|
}
|
|
if remaining := mailboxRemainingSeconds(mail, now); remaining != 0 {
|
|
t.Fatalf("unlimited system mail remaining=%d want=0/no-limit", remaining)
|
|
}
|
|
}
|
|
|
|
func TestMailboxAttachmentRawProjectsLiveStackableUsePeriod(t *testing.T) {
|
|
now := time.Date(2026, 8, 19, 4, 0, 0, 0, time.UTC)
|
|
expiresAt := now.Add(30 * 24 * time.Hour)
|
|
raw := mailboxAttachmentRaw(dnfrepo.MailAttachment{
|
|
ItemID: 490007794,
|
|
Count: 1,
|
|
ExpireAt: expiresAt,
|
|
Extra: map[string]string{"item_kind": "stackable", "stackable_type": "[waste]"},
|
|
}, now)
|
|
if got := binary.LittleEndian.Uint32(raw[0x38:0x3c]); got != uint32(expiresAt.Unix()) {
|
|
t.Fatalf("attachment expire=%d want=%d", got, expiresAt.Unix())
|
|
}
|
|
if got := binary.LittleEndian.Uint16(raw[0x0b:0x0d]); got != math.MaxUint16 {
|
|
t.Fatalf("attachment use period=%d want=%d", got, uint16(math.MaxUint16))
|
|
}
|
|
raw = mailboxAttachmentRaw(dnfrepo.MailAttachment{
|
|
ItemID: 490007794,
|
|
Count: 1,
|
|
ExpireAt: now.Add(9*time.Second + time.Nanosecond),
|
|
Extra: map[string]string{"item_kind": "stackable"},
|
|
}, now)
|
|
if got := binary.LittleEndian.Uint16(raw[0x0b:0x0d]); got != 10 {
|
|
t.Fatalf("final attachment use period=%d want=10", got)
|
|
}
|
|
}
|
|
|
|
func TestMailboxSnapshotProjectsCurrentAttachmentRowAndCreatureFraming(t *testing.T) {
|
|
now := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
|
|
raw := make([]byte, mailboxItemRawSize)
|
|
binary.LittleEndian.PutUint16(raw[0:2], 14)
|
|
binary.LittleEndian.PutUint32(raw[2:6], 9999)
|
|
binary.LittleEndian.PutUint32(raw[6:10], 99)
|
|
binary.LittleEndian.PutUint16(raw[0x0b:0x0d], 55)
|
|
raw[0x0d] = 2
|
|
binary.LittleEndian.PutUint32(raw[0x0e:0x12], 12345)
|
|
raw[0x12] = 6
|
|
raw[0x13] = 7
|
|
binary.LittleEndian.PutUint16(raw[0x14:0x16], 8)
|
|
raw[0x20] = 0xA5
|
|
|
|
mail := dnfrepo.MailRecord{
|
|
MailID: "7",
|
|
SenderCharacterID: "11",
|
|
SenderName: "发件人",
|
|
Body: "附件邮件",
|
|
CreatedAt: now.Add(-time.Hour),
|
|
ExpireAt: now.Add(time.Hour),
|
|
Gold: 50,
|
|
Metadata: map[string]string{"mail_type": "4"},
|
|
Attachments: []dnfrepo.MailAttachment{{
|
|
ItemID: 9999,
|
|
Count: 2,
|
|
Bind: true,
|
|
RawEntry: raw,
|
|
Extra: map[string]string{"mailbox_equipment_type": "creature"},
|
|
}},
|
|
}
|
|
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 3, now)
|
|
if err != nil {
|
|
t.Fatalf("build snapshot: %v", err)
|
|
}
|
|
if body[0] != 1 || body[1] != 0 {
|
|
t.Fatalf("snapshot header = % X, want one initial summary", body[:2])
|
|
}
|
|
offset := 2
|
|
// The leading dword is this slot's claim object; the letter id keying the row
|
|
// sits at its tail. See the claim-object note in body.go.
|
|
wantClaimObject, err := MailboxAttachmentClaimObject(7, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != wantClaimObject {
|
|
t.Fatalf("summary claim object = %d, want %d", got, wantClaimObject)
|
|
}
|
|
offset += 4
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 11 {
|
|
t.Fatalf("summary sender ID = %d, want 11", got)
|
|
}
|
|
offset += 4
|
|
senderLength := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
|
|
offset += 4 + senderLength
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 50 {
|
|
t.Fatalf("summary gold = %d, want 50", got)
|
|
}
|
|
offset += 4
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 9999 || body[offset+4] != 1 {
|
|
t.Fatalf("summary item header = % X", body[offset:offset+5])
|
|
}
|
|
offset += 5
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 2 {
|
|
t.Fatalf("summary item count = %d, want transferred count 2", got)
|
|
}
|
|
offset += 4 + 2 + 1 + 4 + 1 + 1 + 2
|
|
if !bytes.Equal(body[offset:offset+5], []byte{0, 0, 0, 0, 0}) {
|
|
t.Fatalf("creature framing = % X, want u32 zero plus zero detail flag", body[offset:offset+5])
|
|
}
|
|
offset += 5
|
|
wantRaw := append([]byte(nil), raw...)
|
|
binary.LittleEndian.PutUint16(wantRaw[0:2], 0)
|
|
binary.LittleEndian.PutUint32(wantRaw[2:6], 9999)
|
|
binary.LittleEndian.PutUint32(wantRaw[6:10], 2)
|
|
if !bytes.Equal(body[offset:offset+mailboxItemRawSize], wantRaw) {
|
|
t.Fatalf("projected 0x77 item row = % X, want % X", body[offset:offset+mailboxItemRawSize], wantRaw)
|
|
}
|
|
}
|
|
|
|
// The runtime PVF stores `[equipment type]` as a backtick-quoted bracket tag,
|
|
// so the gates that decide the current client's optional summary fields must
|
|
// accept `[creature]` and `[weapon avatar]` exactly as they are persisted.
|
|
func TestMailboxSummaryOptionalFieldGatesFollowRuntimePVFEquipmentTags(t *testing.T) {
|
|
cases := []struct {
|
|
tag string
|
|
class26 bool
|
|
avatar bool
|
|
}{
|
|
{tag: "[creature]", class26: true},
|
|
{tag: "creature", class26: true},
|
|
{tag: "`[creature]`", class26: true},
|
|
{tag: "[weapon avatar]", avatar: true},
|
|
{tag: "`[hat avatar]`", avatar: true},
|
|
{tag: "[aurora avatar]", avatar: true},
|
|
{tag: "weapon avatar", avatar: true},
|
|
{tag: "[artifact red]"},
|
|
{tag: "[weapon]"},
|
|
{tag: "[coat]"},
|
|
{tag: ""},
|
|
}
|
|
for _, testCase := range cases {
|
|
attachment := dnfrepo.MailAttachment{Extra: map[string]string{"mailbox_equipment_type": testCase.tag}}
|
|
if got := mailboxAttachmentRequiresClass26(attachment); got != testCase.class26 {
|
|
t.Fatalf("tag %q class26 = %v, want %v", testCase.tag, got, testCase.class26)
|
|
}
|
|
if got := mailboxAttachmentRequiresAvatarBlobs(attachment); got != testCase.avatar {
|
|
t.Fatalf("tag %q avatar blobs = %v, want %v", testCase.tag, got, testCase.avatar)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMailboxSnapshotWritesAvatarOptionalBlobsAfterItemRow(t *testing.T) {
|
|
now := time.Date(2026, 9, 3, 1, 12, 0, 0, time.UTC)
|
|
raw := make([]byte, mailboxItemRawSize)
|
|
socket := make([]byte, mailboxAvatarSocketBytes)
|
|
for index := range socket {
|
|
socket[index] = byte(index + 1)
|
|
}
|
|
|
|
newMail := func(extra map[string]string) dnfrepo.MailRecord {
|
|
return dnfrepo.MailRecord{
|
|
MailID: "8",
|
|
SenderName: "GM",
|
|
CreatedAt: now,
|
|
ExpireAt: now.Add(time.Hour),
|
|
Attachments: []dnfrepo.MailAttachment{{
|
|
ItemID: 112600001,
|
|
Count: 1,
|
|
RawEntry: raw,
|
|
Extra: extra,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// The summary is two header bytes, the fixed sender/gold/item fields, then
|
|
// the 0x77 row. Keep the offset explicit so the regression locks the wire
|
|
// boundary between the row, the two blobs and the remaining time.
|
|
rowEnd := 2 + 4 + 4 + 4 + 2 + 4 + 4 + 1 + 4 + 2 + 1 + 4 + 1 + 1 + 2 + mailboxItemRawSize
|
|
|
|
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{newMail(map[string]string{
|
|
"mailbox_equipment_type": "[weapon avatar]",
|
|
"avatar_socket_data": hex.EncodeToString(socket),
|
|
"tail_data": "0100020003000400",
|
|
})}, 0, now)
|
|
if err != nil {
|
|
t.Fatalf("build avatar snapshot: %v", err)
|
|
}
|
|
want := make([]byte, 0, 8+mailboxAvatarSocketBytes+mailboxAvatarColorBytes)
|
|
want = binary.LittleEndian.AppendUint32(want, mailboxAvatarSocketBytes)
|
|
want = append(want, socket...)
|
|
want = binary.LittleEndian.AppendUint32(want, mailboxAvatarColorBytes)
|
|
// Only the leading colour pair fits the client's single-DWORD target; the
|
|
// rest of an over-long durable tail_data must never reach the wire.
|
|
want = append(want, 0x01, 0x00, 0x02, 0x00)
|
|
if got := body[rowEnd : rowEnd+len(want)]; !bytes.Equal(got, want) {
|
|
t.Fatalf("avatar blobs = % X, want % X", got, want)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[rowEnd+len(want) : rowEnd+len(want)+4]); got != 3600 {
|
|
t.Fatalf("avatar remaining seconds = %d, want 3600", got)
|
|
}
|
|
|
|
body, err = buildMailboxListNotification([]dnfrepo.MailRecord{newMail(map[string]string{
|
|
"mailbox_equipment_type": "[weapon avatar]",
|
|
})}, 0, now)
|
|
if err != nil {
|
|
t.Fatalf("build bare avatar snapshot: %v", err)
|
|
}
|
|
if got := body[rowEnd : rowEnd+8]; !bytes.Equal(got, make([]byte, 8)) {
|
|
t.Fatalf("bare avatar blobs = % X, want two zero lengths", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[rowEnd+8 : rowEnd+12]); got != 3600 {
|
|
t.Fatalf("bare avatar remaining seconds = %d, want 3600", got)
|
|
}
|
|
|
|
body, err = buildMailboxListNotification([]dnfrepo.MailRecord{newMail(map[string]string{
|
|
"mailbox_equipment_type": "[weapon]",
|
|
"avatar_socket_data": hex.EncodeToString(socket),
|
|
})}, 0, now)
|
|
if err != nil {
|
|
t.Fatalf("build ordinary equipment snapshot: %v", err)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[rowEnd : rowEnd+4]); got != 3600 {
|
|
t.Fatalf("ordinary equipment remaining seconds = %d, want the row to be followed by the trailer", got)
|
|
}
|
|
}
|
|
|
|
func TestMailboxQueryUsesCurrentOp324Layout(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "88",
|
|
Name: "receiver",
|
|
Job: "3",
|
|
Level: 90,
|
|
Stats: map[string]int64{"grow_type": 2},
|
|
})
|
|
body := append(dstrings("receiver"), 0x01)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketQueryCharacInfoMailbox),
|
|
Repositories: repos,
|
|
Body: body,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
want := []byte{0x01}
|
|
want = append(want, dstrings("receiver")...)
|
|
want = append(want, 0x02, 90, 0, 0x03, 0x00, 0x01, 0x00, 0x00)
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketQueryCharacInfoMailbox, want)
|
|
}
|
|
|
|
func TestMailboxQueryRejectsMissingDurableProjection(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := dnfrepomemory.NewMemoryGroup()
|
|
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{
|
|
CharacterID: "88",
|
|
Name: "receiver",
|
|
Job: "not-a-current-job",
|
|
Level: 90,
|
|
Stats: map[string]int64{"grow_type": 2},
|
|
})
|
|
body := append(dstrings("receiver"), 0x01)
|
|
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
|
Opcode: uint16(dnfenum.CmdPacketQueryCharacInfoMailbox),
|
|
Repositories: repos,
|
|
Body: body,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, dnfenum.CmdPacketQueryCharacInfoMailbox, []byte{0x00, 0x01})
|
|
}
|
|
|
|
func TestMailboxMalformedCurrentBodiesAreRejectedWithoutMutation(t *testing.T) {
|
|
for _, opcode := range []dnfenum.CmdPacket{
|
|
dnfenum.CmdPacketMailboxSend,
|
|
dnfenum.CmdPacketMailboxExtractItem,
|
|
dnfenum.CmdPacketMailboxOpen,
|
|
dnfenum.CmdPacketChangeLetterStat,
|
|
dnfenum.CmdPacketMultiMailboxSend,
|
|
dnfenum.CmdPacketQueryCharacInfoMailbox,
|
|
} {
|
|
t.Run(strconv.Itoa(int(opcode)), func(t *testing.T) {
|
|
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
|
Opcode: uint16(opcode),
|
|
Body: []byte{0xFF, 0x00, 0x01},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle error = %v", err)
|
|
}
|
|
assertOneMailboxResponse(t, got, opcode, []byte{0x00, 0x01})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCurrentMailboxPostageMatchesIDAFormula(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
gold int64
|
|
attachments int
|
|
attachmentValue int64
|
|
want int64
|
|
}{
|
|
{name: "message only", want: 100},
|
|
{name: "gold", gold: 50, want: 102},
|
|
{name: "one attachment", attachments: 1, attachmentValue: 200, want: 1110},
|
|
{name: "two attachments", attachments: 2, attachmentValue: 400, want: 2120},
|
|
{name: "all components", gold: 1000, attachments: 2, attachmentValue: 400, want: 2170},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := currentMailboxPostage(test.gold, test.attachments, test.attachmentValue)
|
|
if err != nil {
|
|
t.Fatalf("currentMailboxPostage error = %v", err)
|
|
}
|
|
if got != test.want {
|
|
t.Fatalf("currentMailboxPostage = %d, want %d", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertOneMailboxResponse(t *testing.T, got alignedcmd.Result, opcode dnfenum.CmdPacket, body []byte) {
|
|
t.Helper()
|
|
if !got.Handled {
|
|
t.Fatalf("%d should be handled", opcode)
|
|
}
|
|
if !got.ResponseAllowed {
|
|
t.Fatalf("%d should allow response", opcode)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("response count = %d, want 1", len(got.UpperResponses))
|
|
}
|
|
assertMailboxResponse(t, got.UpperResponses[0], uint16(opcode), dnfproto.DefaultChannelClassification, body)
|
|
}
|
|
|
|
func assertMailboxExtractSuccessWithIncrementalRemove(t *testing.T, got alignedcmd.Result, ack []byte) {
|
|
t.Helper()
|
|
if !got.Handled || !got.ResponseAllowed {
|
|
t.Fatalf("mailbox extract should be handled with a response: %+v", got)
|
|
}
|
|
if len(got.UpperResponses) != 1 {
|
|
t.Fatalf("response count = %d, want only op95 ACK before item refresh", len(got.UpperResponses))
|
|
}
|
|
assertMailboxResponse(t, got.UpperResponses[0], uint16(dnfenum.CmdPacketMailboxExtractItem), dnfproto.DefaultChannelClassification, ack)
|
|
if len(got.PostItemSlotRefreshResponses) != 1 {
|
|
t.Fatalf("post-item response count = %d, want one class0/0x62 removal", len(got.PostItemSlotRefreshResponses))
|
|
}
|
|
if len(ack) < 5 {
|
|
t.Fatalf("short mailbox extract ACK: % X", ack)
|
|
}
|
|
count := int(binary.LittleEndian.Uint32(ack[1:5]))
|
|
if len(ack) != 5+count*12 {
|
|
t.Fatalf("mailbox extract ACK count=%d body=% X", count, ack)
|
|
}
|
|
// Each row is (letter id, claim object, mode). The claim object is either the
|
|
// letter id itself (a gold-only row) or that letter's encoded attachment slot;
|
|
// a letter with several attachments reports one row per slot, and the remove
|
|
// notification then names each closed letter once.
|
|
mailIDs := make([]uint32, 0, count)
|
|
for index := 0; index < count; index++ {
|
|
offset := 5 + index*12
|
|
mailID := binary.LittleEndian.Uint32(ack[offset : offset+4])
|
|
claimObject := binary.LittleEndian.Uint32(ack[offset+4 : offset+8])
|
|
status := binary.LittleEndian.Uint32(ack[offset+8 : offset+12])
|
|
decodedMailID, _, _ := DecodeMailboxClaimObject(claimObject)
|
|
if mailID == 0 || decodedMailID != mailID || status != 0 {
|
|
t.Fatalf("mailbox extract ACK result %d mail=%d claim_object=%d status=%d", index, mailID, claimObject, status)
|
|
}
|
|
if len(mailIDs) == 0 || mailIDs[len(mailIDs)-1] != mailID {
|
|
mailIDs = append(mailIDs, mailID)
|
|
}
|
|
}
|
|
assertMailboxResponse(t, got.PostItemSlotRefreshResponses[0], mailboxRemoveNotificationMessageID, 0, buildMailboxRemoveNotification(mailIDs))
|
|
}
|
|
|
|
func assertMailboxResponse(t *testing.T, response alignedcmd.UpperResponse, messageID uint16, classification byte, body []byte) {
|
|
t.Helper()
|
|
if response.MsgID != messageID {
|
|
t.Fatalf("msgID = %d, want %d", response.MsgID, messageID)
|
|
}
|
|
if response.Classification != classification {
|
|
t.Fatalf("classification = %d, want %d", response.Classification, classification)
|
|
}
|
|
if !response.AllowCodec {
|
|
t.Fatalf("%d should use normal game upper codec path", messageID)
|
|
}
|
|
if !bytes.Equal(response.Body, body) {
|
|
t.Fatalf("body = % X, want % X", response.Body, body)
|
|
}
|
|
}
|
|
|
|
func mustSaveCharacter(t *testing.T, repos dnfrepo.Group, record dnfrepo.CharacterRecord) {
|
|
t.Helper()
|
|
if err := repos.Character.Save(context.Background(), record); err != nil {
|
|
t.Fatalf("save character %s: %v", record.CharacterID, err)
|
|
}
|
|
}
|
|
|
|
func mailboxClaimTestResolver(start int16, end int16, stackLimit int64) alignedcmd.MailboxItemResolver {
|
|
return func(uint32) (alignedcmd.MailboxItemResolution, error) {
|
|
return alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable",
|
|
SlotStart: start,
|
|
SlotEnd: end,
|
|
StackLimit: stackLimit,
|
|
PreferQuickSlots: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
func TestAddAttachmentStacksCurrentPVFMaterialDespiteRawAndTracingMetadata(t *testing.T) {
|
|
raw := make([]byte, mailboxItemRawSize)
|
|
binary.LittleEndian.PutUint32(raw[0x06:0x0a], 9)
|
|
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:65": {
|
|
ItemID: 3244, Count: 9, RawEntry: raw,
|
|
Extra: map[string]string{"source": "dungeon_pvf_drop_pickup", "pvf_path": "stackable/old.stk"},
|
|
},
|
|
}}
|
|
resolution := alignedcmd.MailboxItemResolution{
|
|
Kind: "stackable", PVFPath: "stackable/material/crystal_pure.stk", StackableType: "[material]",
|
|
SlotStart: 65, SlotEnd: 120, StackLimit: 0,
|
|
}
|
|
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{
|
|
ItemID: 3244, Count: 4, Extra: map[string]string{"source": "gm_mail", "last_grant_source": "mail"},
|
|
}, resolution)
|
|
if err != nil {
|
|
t.Fatalf("addAttachmentToInventory error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(changed, []int16{65}) {
|
|
t.Fatalf("changed slots = %v", changed)
|
|
}
|
|
if len(inventory.Slots) != 1 {
|
|
t.Fatalf("mail claim created another slot: %+v", inventory.Slots)
|
|
}
|
|
stack := inventory.Slots["0:65"]
|
|
if stack.Count != 13 || binary.LittleEndian.Uint32(stack.RawEntry[0x06:0x0a]) != 13 {
|
|
t.Fatalf("merged mail stack = %+v raw=%x", stack, stack.RawEntry[:10])
|
|
}
|
|
if stack.Extra["item_kind"] != "stackable" || stack.Extra["pvf_path"] != "stackable/material/crystal_pure.stk" ||
|
|
stack.Extra["stackable_type"] != "[material]" {
|
|
t.Fatalf("canonical mail metadata = %+v", stack.Extra)
|
|
}
|
|
}
|
|
|
|
func currentSendBody(
|
|
recipient string,
|
|
gold uint32,
|
|
attachments []SendAttachment,
|
|
message string,
|
|
special uint32,
|
|
global bool,
|
|
multi bool,
|
|
) []byte {
|
|
out := dstrings(recipient)
|
|
out = appendU32(out, gold)
|
|
if multi {
|
|
out = append(out, byte(len(attachments)))
|
|
}
|
|
for _, attachment := range attachments {
|
|
out = append(out, attachment.ListType)
|
|
var slot [2]byte
|
|
binary.LittleEndian.PutUint16(slot[:], attachment.SlotIndex)
|
|
out = append(out, slot[:]...)
|
|
out = appendU32(out, attachment.ItemID)
|
|
out = appendU32(out, attachment.Count)
|
|
}
|
|
out = append(out, dstrings(message)...)
|
|
out = appendU32(out, special)
|
|
if global {
|
|
out = append(out, 1)
|
|
} else {
|
|
out = append(out, 0)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func currentMailIDList(ids ...uint32) []byte {
|
|
out := appendU32(nil, uint32(len(ids)))
|
|
for _, id := range ids {
|
|
out = appendU32(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// currentExtractAck is the acknowledgement for letters that handed over their
|
|
// first attachment slot. sub_27156D0 reads each triple as (letter id, claim
|
|
// object, mode): the letter id keys its container and the claim object selects
|
|
// the slot through sub_2708220, so both halves have to be on the wire.
|
|
func currentExtractAck(ids ...uint32) []byte {
|
|
out := []byte{1}
|
|
out = appendU32(out, uint32(len(ids)))
|
|
for _, id := range ids {
|
|
out = appendU32(out, id)
|
|
out = appendU32(out, MailboxAttachmentSlotFlag|id*mailboxMaxAttachmentsPerMail)
|
|
out = appendU32(out, 0)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// currentExtractAckSlots is the acknowledgement for one letter that handed over
|
|
// its first `slots` attachment slots: one (letter id, slot claim object, 0) row
|
|
// per slot, in letter order.
|
|
func currentExtractAckSlots(id uint32, slots int) []byte {
|
|
out := []byte{1}
|
|
out = appendU32(out, uint32(slots))
|
|
for index := 0; index < slots; index++ {
|
|
out = appendU32(out, id)
|
|
out = appendU32(out, MailboxAttachmentSlotFlag|id*mailboxMaxAttachmentsPerMail|uint32(index))
|
|
out = appendU32(out, 0)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// currentExtractAckWholeLetter is the acknowledgement for a letter that had no
|
|
// attachment to hand over, only gold. Such a summary row carries the letter id
|
|
// as its own claim object, so the claim is reported with that value.
|
|
func currentExtractAckWholeLetter(ids ...uint32) []byte {
|
|
out := []byte{1}
|
|
out = appendU32(out, uint32(len(ids)))
|
|
for _, id := range ids {
|
|
out = appendU32(out, id)
|
|
out = appendU32(out, id)
|
|
out = appendU32(out, 0)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func currentStateBody(status uint16, ids ...uint32) []byte {
|
|
out := currentMailIDList(ids...)
|
|
var state [2]byte
|
|
binary.LittleEndian.PutUint16(state[:], status)
|
|
return append(out, state[:]...)
|
|
}
|
|
|
|
func currentStateAck(status uint16, ids ...uint32) []byte {
|
|
out := []byte{1}
|
|
out = appendU32(out, uint32(len(ids)))
|
|
for _, id := range ids {
|
|
out = appendU32(out, id)
|
|
var state [2]byte
|
|
binary.LittleEndian.PutUint16(state[:], status)
|
|
out = append(out, state[:]...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dstrings(values ...string) []byte {
|
|
var out []byte
|
|
for _, value := range values {
|
|
out = appendU32(out, uint32(len([]byte(value))))
|
|
out = append(out, []byte(value)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendU32(out []byte, value uint32) []byte {
|
|
var encoded [4]byte
|
|
binary.LittleEndian.PutUint32(encoded[:], value)
|
|
return append(out, encoded[:]...)
|
|
}
|