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

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

247 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"testing"
"time"
dnfraid "longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 「不管谁背包满了 都不能影响整体翻牌 流程要走完;装不下的走邮件」(操作员 2026-10-02 定案)。
//
// 真机 2026-10-02 16:42(raid 12058625,分片 20261002-019):
//
// game-raid-reward-auto-flip-commit-failed raid_key=12058625 raid_phase=1 flipped=4
// error=runtime raid reward grant is invalid: character=1 item=490008672 count=1:
// dnf dungeon pickup inventory category is full: item=490008672 range=65..120
//
// 牌**翻了**(flipped=4),坏在发奖那一步:`commitRuntimeRaidSettlementRewards` 用一个
// 事务罩住**全队**(`WithinRaidRewards(ctx, characterIDs, …)`),队里第一个背包满的人一
// `return`,整个闭包失败、事务整体回滚 —— 四个人的奖励一个都发不出去,回执也不落,
// 客户端的牌就永远停在「翻不开」(服务器自己在日志里写着
// `client_visible=card_window_stays_open_and_the_flip_never_settles`)。
//
// 本用例钉住四件事:
//
// ① 一个人背包满,**提交照样成功**(改动前这里是红);
// ② 其余成员照常在背包里拿到奖励;
// ③ 满包的那一位改走**邮件**,附件就是他没拿到的那几件;
// ④ 重投同一份回执**不会发第二封**(completion_key 去重)。
func TestRaidRewardOverflowGoesToMailWithoutBlockingTheRaid(t *testing.T) {
// 三个团本共用这一条发奖路径,所以这条口径对安东/卢克同样成立(操作员 2026-10-02:
// 「安徒恩和卢克也应该适用 某些人背包满了不应该拖死全队」)。
// 安东与卢克走的是**同一个分支**:runtimeFiendWarRewardEligible 对它们恒真、
// 发奖循环 / 回执 / 邮件三段都没有任何 RaidMode 判断,唯一差别是奖励目录怎么取
// (runtimeRaidBaseConfig)。所以两个模式各跑一遍就足以钉住。
for _, scenario := range []struct {
name string
apply func(fixture runtimeRaidRewardTestFixture)
}{
{"安东", func(fixture runtimeRaidRewardTestFixture) {
// 夹具默认就是安东:AntonConfig 已设、RaidMode 走 runtimeRaidBaseConfig 的兜底。
fixture.service.raids[fixture.raidKey].RaidMode = runtimeRaidOwnerAnton
}},
{"卢克", func(fixture runtimeRaidRewardTestFixture) {
state := fixture.service.raids[fixture.raidKey]
// 目录预设成夹具那一份,免得 runtimeRaidLukeRewardCatalog 去读真 PVF。
fixture.service.raidLukeReward = fixture.service.raidAntonReward
state.RaidMode = runtimeRaidOwnerLuke
state.LukeConfig = &dnfraid.LukeConfig{Raid: state.AntonConfig}
}},
} {
t.Run(scenario.name, func(t *testing.T) {
fixture := newRuntimeRaidRewardTestFixture(t, false)
scenario.apply(fixture)
runRaidRewardOverflowScenario(t, fixture)
})
}
}
// runRaidRewardOverflowScenario 是这条用例的本体,对安东与卢克各跑一次。
func runRaidRewardOverflowScenario(t *testing.T, fixture runtimeRaidRewardTestFixture) {
t.Helper()
service := fixture.service
// 溢出邮件要用 currentPVFItemCatalog() 把奖励实例化成附件;夹具的目录是用
// newAntonRewardPVFTestItems 现搭的合成 PVF(service 上没有 pvfPath),
// 这里把**夹具解析奖励时用的同一份** source / itemRefs 挂上去。
rewardCatalog := service.raidAntonReward
if rewardCatalog == nil {
t.Fatal("夹具没有奖励目录")
}
service.pvfItemCatalog = &pvfDungeonDropCatalog{
source: rewardCatalog.source,
itemRefs: rewardCatalog.itemRefs,
itemCache: make(map[uint32]dungeonDropItemDefinition),
}
// 把 1002 的主背包整片塞满,任何分页都放不下 —— 真机那次就是 65..120 满。
// 填的是**另一个** item id 且 bind,所以既不能并栈也没有空格。
fillRuntimeRaidRewardTestBag(t, fixture, "1002", 1, 400)
delivery, err := service.commitRuntimeRaidSettlementRewards(
fixture.raidKey,
runtimeRaidDungeonPhaseOne,
fixture.now,
)
if err != nil {
t.Fatalf("背包满的成员把整队的提交拖死了: %v\n"+
"(真机 2026-10-02 16:42 就是这个形状:flipped=4 但 commit-failed,"+
"客户端停在「翻不开」)", err)
}
// ② 没满的那位照常在背包里拿到。
if slots := runtimeRaidRewardTestRefreshSlots(delivery, 1001); len(slots) == 0 {
t.Fatal("背包没满的成员一个格位都没写:奖励没发出去")
}
// ③ 满包的那位一个格位都不该写(整份走邮件,不留半截)。
if slots := runtimeRaidRewardTestRefreshSlots(delivery, 1002); len(slots) != 0 {
t.Fatalf("背包满的成员竟然写进了格位 %v", slots)
}
wantRows := runtimeRaidRewardTestWireRows(t, delivery, 1002)
if len(wantRows) == 0 {
t.Fatal("投递里没有 1002 的奖励行:夹具没造出问题")
}
wantCount := int64(0)
for _, row := range wantRows {
wantCount += int64(row.CountOrAux)
}
// ③ 邮件真的落了,附件对得上。
mails := runtimeRaidRewardTestSystemMails(t, fixture, "1002")
if len(mails) != 1 {
t.Fatalf("1002 的系统邮件 = %d 封, want 1(满包的那一份必须走邮件)", len(mails))
}
if got := mails[0].Metadata["source"]; got != currentRaidRewardOverflowMailSource {
t.Fatalf("邮件 source = %q, want %q", got, currentRaidRewardOverflowMailSource)
}
if len(mails[0].Attachments) == 0 {
t.Fatal("溢出邮件的附件是空的:奖励还是没到玩家手上")
}
attached := int64(0)
for _, attachment := range mails[0].Attachments {
attached += attachment.Count
if got := attachment.Extra["overflow_mail"]; got != "true" {
t.Fatalf("附件缺少 overflow_mail 标记:Extra=%v", attachment.Extra)
}
}
if attached != wantCount {
t.Fatalf("邮件附件总量 = %d, want %d(背包满就得把整份奖励寄出去)", attached, wantCount)
}
// 背包没满的那位不该收到溢出邮件。
if free := runtimeRaidRewardTestSystemMails(t, fixture, "1001"); len(free) != 0 {
t.Fatalf("背包没满的成员也收到了溢出邮件:%+v", free)
}
// ④ 重投同一份回执:不再落第二封。
if _, err := service.commitRuntimeRaidSettlementRewards(
fixture.raidKey,
runtimeRaidDungeonPhaseOne,
fixture.now.Add(time.Minute),
); err != nil {
t.Fatalf("重投失败: %v", err)
}
if again := runtimeRaidRewardTestSystemMails(t, fixture, "1002"); len(again) != 1 {
t.Fatalf("重投之后 1002 的溢出邮件 = %d 封, want 仍为 1 封(completion_key 必须去重)", len(again))
}
}
// fillRuntimeRaidRewardTestBag 把某个角色的主背包 [from,to] 整段填满。
func fillRuntimeRaidRewardTestBag(
t *testing.T,
fixture runtimeRaidRewardTestFixture,
characterID string,
from, to int16,
) {
t.Helper()
ctx := context.Background()
inventory, found, err := fixture.repositories.Inventory.Load(ctx, characterID)
if err != nil || !found {
t.Fatalf("load inventory %s found=%t err=%v", characterID, found, err)
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
for slot := from; slot <= to; slot++ {
inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{
ItemID: 990001,
Count: 1,
Bind: true,
}
}
if err := fixture.repositories.Inventory.Save(ctx, inventory); err != nil {
t.Fatalf("seed full bag for %s: %v", characterID, err)
}
}
// runtimeRaidRewardTestRefreshSlots 取某位成员这次投递刷新了哪些格位。
func runtimeRaidRewardTestRefreshSlots(
delivery runtimeRaidSettlementRewardDelivery,
characterID uint16,
) []int16 {
for _, refresh := range delivery.InventoryRefreshes {
if refresh.CharacterID == characterID {
return refresh.Slots
}
}
return nil
}
// runtimeRaidRewardTestWireRows 取某位成员这次结算的 0x0258 奖励行。
func runtimeRaidRewardTestWireRows(
t *testing.T,
delivery runtimeRaidSettlementRewardDelivery,
characterID uint16,
) []raidRaidRewardTestRow {
t.Helper()
for _, member := range delivery.Rewards.Members {
if member.MemberKey != characterID {
continue
}
rows := make([]raidRaidRewardTestRow, 0, len(member.Rewards))
for _, reward := range member.Rewards {
rows = append(rows, raidRaidRewardTestRow{
ItemIDOrValue: reward.ItemIDOrValue,
CountOrAux: uint32(reward.CountOrAux),
})
}
return rows
}
t.Fatalf("投递里没有成员 %d 的奖励行", characterID)
return nil
}
type raidRaidRewardTestRow struct {
ItemIDOrValue uint32
CountOrAux uint32
}
// runtimeRaidRewardTestSystemMails 读某个角色邮箱里的系统邮件。
func runtimeRaidRewardTestSystemMails(
t *testing.T,
fixture runtimeRaidRewardTestFixture,
characterID string,
) []dnfrepo.MailRecord {
t.Helper()
if fixture.repositories.Mailbox == nil {
t.Fatal("夹具没有邮箱仓库")
}
record, found, err := fixture.repositories.Mailbox.Load(context.Background(), characterID)
if err != nil {
t.Fatalf("load mailbox %s: %v", characterID, err)
}
if !found {
return nil
}
mails := make([]dnfrepo.MailRecord, 0, len(record.Mails))
for _, mail := range record.Mails {
if mail.Metadata["system_mail"] != "true" {
continue
}
mails = append(mails, mail)
}
return mails
}