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