服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
242 lines
7.8 KiB
Go
242 lines
7.8 KiB
Go
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)
|
||
}
|
||
}
|