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