DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,246 @@
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
}