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,222 @@
package dnfbridge
import (
"context"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 攻坚失败返还入场次数。服主口径(2026-10-03):
//
// 「卢克和安徒恩就是攻坚倒计时结束 超时空在此基础上则多加个战斗失败
// 中途放弃不返还任何东西」
//
// 这条测试走的是安徒恩最常见的形态:真开团(所以三条次数与入场材料都是**真的**
// 被扣掉的,不是夹具假装的),然后倒计时到点 → 发失败演出 → 返还,最后核对次数与
// 材料都回到开团前。返还之后必须能重放而不多发一份:三个调用点全都在定时器回调
// 里,回调重复跑是设计上允许的。
func TestRuntimeRaidFailureRefundsAdmissionCountersAndEntryTicket(t *testing.T) {
now := time.Now().UTC()
service, repositories, catalog, session := newRuntimeRaidAdmissionCounterTest(t, 2, 1001)
// 入场材料要真的能发回背包,所以这条用例必须有能解析 10096296 的道具目录;
// 夹具本身不建目录(开团只扣不发,用不到)。
service.pvfItemCatalog = newRuntimeRaidEntryRefundItemCatalog(t)
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
t.Fatal(err)
}
// 开团成功:三条次数各扣 1,材料券从 2 掉到 1。
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", currentRaidAntonDailyPolicyKey, 1)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.weekly", 1)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.character.weekly", 1)
if got := runtimeRaidEntryTicketTotal(t, repositories, "1001"); got != 1 {
t.Fatalf("开团后入场材料 = %d, want 1", got)
}
state := runtimeRaidFailureStateForTest(t, service, now)
failed, err := service.expireRuntimeRaidPhase(state.RaidKey, runtimeRaidDungeonPhaseOne, now)
if err != nil {
t.Fatalf("倒计时到点:%v", err)
}
// 与 scheduleRuntimeRaidPhaseExpiry 的回调同序:先演出,再返还。
if err := service.publishFailedRuntimeRaid(failed); err != nil {
t.Fatalf("发失败演出:%v", err)
}
if err := service.refundRuntimeRaidEntryOnFailure(failed, now); err != nil {
t.Fatalf("攻坚失败返还:%v", err)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", currentRaidAntonDailyPolicyKey, 0)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.account.weekly", 0)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.character.weekly", 0)
if got := runtimeRaidEntryTicketTotal(t, repositories, "1001"); got != 2 {
t.Fatalf("返还后入场材料 = %d, want 2(开团前是 2)", got)
}
// 重放:定时器回调可能再跑一次,次数不能再涨、材料不能再发。
if err := service.refundRuntimeRaidEntryOnFailure(failed, now); err != nil {
t.Fatalf("重放返还:%v", err)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", currentRaidAntonDailyPolicyKey, 0)
if got := runtimeRaidEntryTicketTotal(t, repositories, "1001"); got != 2 {
t.Fatalf("重放后入场材料 = %d, want 2 —— 返还重复发了一份", got)
}
}
// 中途放弃不返还任何东西:这是服主口径的另一半,也是这套返还最容易被动摇的地方
// (把返还挂到 abandonRuntimeRaid 上只需要一行)。
//
// 断言的是**扣掉的还在**:放弃之后三条次数仍是 1、材料券仍是 1。谁要是把返还接进
// 放弃路径,这条会立刻红。
func TestRuntimeRaidAbandonKeepsTheEntryCharge(t *testing.T) {
now := time.Now().UTC()
service, repositories, catalog, session := newRuntimeRaidAdmissionCounterTest(t, 2, 1001)
service.pvfItemCatalog = newRuntimeRaidEntryRefundItemCatalog(t)
if err := service.handleOnlineStartRaid(session, uint16(dnfenum.CmdPacketStartRaid), nil); err != nil {
t.Fatal(err)
}
state, ok := service.runtimeRaidForMember(1001)
if !ok {
t.Fatal("开团后找不到攻坚队")
}
abandoned, err := service.abandonRuntimeRaid(state.RaidKey, 1001, now)
if err != nil {
t.Fatalf("中途放弃:%v", err)
}
if abandoned.Lifecycle.Status != runtimeRaidLifecycleAbandoned {
t.Fatalf("放弃后 status=%d, want abandoned", abandoned.Lifecycle.Status)
}
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", currentRaidAntonDailyPolicyKey, 1)
assertRuntimeRaidAdmissionUsed(t, repositories, catalog, "1001", "raid.anton.character.weekly", 1)
if got := runtimeRaidEntryTicketTotal(t, repositories, "1001"); got != 1 {
t.Fatalf("放弃后入场材料 = %d, want 1(放弃不返还)", got)
}
}
// 没开起来的团(没扣过费)返还必须是纯 no-op,而且一次持久化都不该发生:这条守卫
// 是"中途放弃不返还"的另一半保障,也是测试里大量合成 state 能安全调用它的原因。
func TestRuntimeRaidEntryRefundIgnoresAnUnpaidRaid(t *testing.T) {
service, repositories, _, _ := newRuntimeRaidAdmissionCounterTest(t, 2, 1001)
service.pvfItemCatalog = newRuntimeRaidEntryRefundItemCatalog(t)
for name, state := range map[string]runtimeRaidState{
"never started": {RaidKey: 7, StartAttempt: 1, ChargedMembers: []uint16{1001}},
"started but unpaid": {
RaidKey: 7, StartAttempt: 1, Started: true, ChargedMembers: []uint16{1001},
},
"paid but nobody charged": {
RaidKey: 7, StartAttempt: 1, Started: true, EntryCostPaid: true,
},
} {
if err := service.refundRuntimeRaidEntryOnFailure(state, time.Now().UTC()); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
if got := runtimeRaidEntryTicketTotal(t, repositories, "1001"); got != 2 {
t.Fatalf("no-op 返还之后入场材料 = %d, want 2(没扣过就不该发)", got)
}
}
// 超时空的入场材料由 PVF [RETRY] 驱动的 grantRuntimeFiendWarRetry 退回,而
// fiendwar.etc 把入场券和失败重试券写成同一个道具(490007844)。两条链路都跑就会
// 发两份,所以通用返还必须让开。这里钉的就是这条让开规则本身。
func TestRuntimeRaidEntryMaterialRefundYieldsToTheFiendWarRetryTicket(t *testing.T) {
cases := []struct {
name string
state runtimeRaidState
want bool
}{
{
name: "超时空且 [RETRY] 开着:重试券链路独占",
state: runtimeRaidState{
RaidMode: runtimeRaidOwnerFiendWar,
FiendWarConfig: &raid.FiendWarConfig{Retry: true},
},
want: true,
},
{
name: "超时空但 [RETRY] 关着:没人退,通用返还接手",
state: runtimeRaidState{
RaidMode: runtimeRaidOwnerFiendWar,
FiendWarConfig: &raid.FiendWarConfig{},
},
want: false,
},
{name: "安徒恩没有重试券这回事", state: runtimeRaidState{RaidMode: runtimeRaidOwnerAnton}, want: false},
{
name: "超时空但没有 owner 配置",
state: runtimeRaidState{RaidMode: runtimeRaidOwnerFiendWar},
want: false,
},
}
for _, test := range cases {
if got := runtimeRaidEntryMaterialRefundOwnedByRetry(test.state); got != test.want {
t.Fatalf("%s: owned=%t want=%t", test.name, got, test.want)
}
}
}
// runtimeRaidFailureStateForTest 把一个真开起来的安徒恩团推成"倒计时刚好到点":
// expireRuntimeRaidPhase 只认活跃阶段 + 已过期的 PhaseDeadline。开团时
// StartDelaySeconds=3600,团停在 Preparing,所以这里要把阶段状态与截止时间改掉,
// 与 raid_phase_timeout_flow_test.go 的做法一致。
func runtimeRaidFailureStateForTest(t *testing.T, service *Service, now time.Time) runtimeRaidState {
t.Helper()
state, ok := service.runtimeRaidForMember(1001)
if !ok || !state.Started || !state.EntryCostPaid {
t.Fatalf("开团后的状态不是「已开始且已付费」:found=%t state=%+v", ok, state)
}
service.raidMu.Lock()
live := service.raids[state.RaidKey]
if live == nil {
service.raidMu.Unlock()
t.Fatal("攻坚队行在开团后消失了")
}
live.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
live.Lifecycle.Phase = runtimeRaidDungeonPhaseOne
live.Lifecycle.PhaseStartedAt = now.Add(-time.Hour)
live.Lifecycle.PhaseDeadline = now.Add(-time.Minute)
service.raidMu.Unlock()
return state
}
// runtimeRaidEntryTicketTotal 数的是整个背包里入场材料的**总量**,不是某一个格子。
// 开团时扣的是夹具手写的 "0:0",而发回背包走的是 PVF 落点([material] 的 121..176),
// 两者未必是同一格。
func runtimeRaidEntryTicketTotal(t *testing.T, repositories dnfrepo.Group, characterID string) int64 {
t.Helper()
record, found, err := repositories.Inventory.Load(context.Background(), characterID)
if err != nil || !found {
t.Fatalf("load inventory %s found=%t err=%v", characterID, found, err)
}
total := int64(0)
for _, stack := range record.Slots {
if stack.ItemID == int64(runtimeRaidEntryItemID) {
total += stack.Count
}
}
return total
}
// newRuntimeRaidEntryRefundItemCatalog 是只声明入场材料一个道具的最小道具目录:
// 返还走的是 ResolveItem -> 落点 -> 存档那条真链路,缺了目录会直接报错。
func newRuntimeRaidEntryRefundItemCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
catalog, err := newPVFDungeonDropCatalog(dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": fmt.Sprintf(
"%d `material/raid_entry_refund_ticket.stk`\n", runtimeRaidEntryItemID),
"equipment/equipment.lst": "",
"stackable/material/raid_entry_refund_ticket.stk": "[name] `Raid Entry Ticket`\n" +
"[stackable type] `[material]`\n[stack limit] 999\n",
})
if err != nil {
t.Fatalf("build raid entry refund item catalog: %v", err)
}
return catalog
}