Files
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

223 lines
9.6 KiB
Go
Raw Permalink 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"
"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
}