服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
224 lines
9.5 KiB
Go
224 lines
9.5 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// The reward gate reads the phase's own [MINIMAL DUNGEON CLEAR COUNT]
|
|
// (runtimeRaidMemberClearedGateLocked -> runtimeRaidPhaseMinimalClearRequirement).
|
|
//
|
|
// The official reward condition is 「1阶段:无条件 / 2阶段:通关2个以上的地下城」,
|
|
// which is exactly the pair Luke declares, so the card window must withhold for a
|
|
// member whose 地下城通关 column has not reached the phase's requirement.
|
|
//
|
|
// lukeClearRequirementConfig is that shape: phase one 0, phase two 2, and the
|
|
// single-mode phase -- whose PVF block omits the section entirely -- left at the
|
|
// shared parser's default of 1 (anton_pvf.go:503), NOT inheriting team phase one's 0.
|
|
// All three values are pinned against the shipped PVF by
|
|
// raid.TestRealScriptPVFLukePhaseMechanismTables.
|
|
func lukeClearRequirementConfig() raid.AntonConfig {
|
|
return raid.AntonConfig{
|
|
Phases: []raid.AntonPhase{
|
|
{Name: "进入源生宫殿", MinimalDungeonClearCount: 0},
|
|
{Name: "进入卓越圣殿", MinimalDungeonClearCount: 2},
|
|
},
|
|
SingleMode: &raid.AntonSingleModeConfig{
|
|
Name: "进入源生宫殿",
|
|
Phase: raid.AntonPhase{Name: "进入源生宫殿", MinimalDungeonClearCount: 1},
|
|
},
|
|
}
|
|
}
|
|
|
|
// antonClearRequirementConfig is the shipped etc/raid/anton.etc shape: both team
|
|
// phases declare 1 (anton_pvf_test.go:544/:565, read from the real PVF) and the
|
|
// single phase is defaulted to 1.
|
|
func antonClearRequirementConfig() raid.AntonConfig {
|
|
return raid.AntonConfig{
|
|
Phases: []raid.AntonPhase{
|
|
{Name: "阻截安徒恩", MinimalDungeonClearCount: 1},
|
|
{Name: "灭杀安徒恩", MinimalDungeonClearCount: 1},
|
|
},
|
|
SingleMode: &raid.AntonSingleModeConfig{
|
|
Phase: raid.AntonPhase{Name: "阻截安徒恩", MinimalDungeonClearCount: 1},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestRuntimeRaidMemberClearedGateLockedUsesThePhaseClearRequirement(t *testing.T) {
|
|
t.Parallel()
|
|
lukeConfig := lukeClearRequirementConfig()
|
|
antonConfig := antonClearRequirementConfig()
|
|
luke := &raid.LukeConfig{Raid: &lukeConfig}
|
|
|
|
lukeState := func(phase byte) runtimeRaidState {
|
|
return runtimeRaidState{
|
|
RaidMode: runtimeRaidOwnerLuke,
|
|
LukeConfig: luke,
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: phase},
|
|
}
|
|
}
|
|
antonState := func(phase byte) runtimeRaidState {
|
|
return runtimeRaidState{
|
|
RaidMode: runtimeRaidOwnerAnton,
|
|
AntonConfig: &antonConfig,
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: phase},
|
|
}
|
|
}
|
|
lukeSingle := lukeState(runtimeRaidDungeonPhaseOne)
|
|
lukeSingle.TailFlag = 1
|
|
antonSingle := antonState(runtimeRaidDungeonPhaseOne)
|
|
antonSingle.TailFlag = 1
|
|
|
|
tests := []struct {
|
|
name string
|
|
state runtimeRaidState
|
|
characterID uint16
|
|
cleared uint32
|
|
want bool
|
|
}{
|
|
{"luke phase two withholds at zero clears", lukeState(runtimeRaidDungeonPhaseTwo), 19, 0, false},
|
|
{"luke phase two withholds at one clear", lukeState(runtimeRaidDungeonPhaseTwo), 19, 1, false},
|
|
{"luke phase two pays at two clears", lukeState(runtimeRaidDungeonPhaseTwo), 19, 2, true},
|
|
{"luke phase two pays above the requirement", lukeState(runtimeRaidDungeonPhaseTwo), 19, 3, true},
|
|
{"luke phase one is unconditional", lukeState(runtimeRaidDungeonPhaseOne), 19, 0, true},
|
|
{"luke phase one pays a clearing member too", lukeState(runtimeRaidDungeonPhaseOne), 19, 3, true},
|
|
{"luke single mode withholds at zero clears", lukeSingle, 19, 0, false},
|
|
{"luke single mode pays at one clear", lukeSingle, 19, 1, true},
|
|
|
|
{"anton phase one withholds at zero clears", antonState(runtimeRaidDungeonPhaseOne), 19, 0, false},
|
|
{"anton phase one pays at one clear", antonState(runtimeRaidDungeonPhaseOne), 19, 1, true},
|
|
{"anton phase two withholds at zero clears", antonState(runtimeRaidDungeonPhaseTwo), 19, 0, false},
|
|
{"anton phase two pays at one clear", antonState(runtimeRaidDungeonPhaseTwo), 19, 1, true},
|
|
{"anton single mode withholds at zero clears", antonSingle, 19, 0, false},
|
|
{"anton single mode pays at one clear", antonSingle, 19, 1, true},
|
|
|
|
{"character zero is never eligible", lukeState(runtimeRaidDungeonPhaseTwo), 0, 9, false},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
test.state.Lifecycle.PhaseClearCounts = map[uint16]uint32{19: test.cleared}
|
|
if got := runtimeRaidMemberClearedGateLocked(test.state, test.characterID); got != test.want {
|
|
t.Fatalf("cleared=%d eligible=%t want %t", test.cleared, got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRuntimeRaidClearedGateKeepsTheLegacyRuleWithoutACatalog covers the states a
|
|
// hand-built fixture or an unavailable config produces. runtimeRaidPhaseMinimalClearRequirement
|
|
// reports them as "no declaration", and a missing declaration must not be read as a
|
|
// declared 0 -- otherwise every member of a catalog-less raid would be handed a card.
|
|
func TestRuntimeRaidClearedGateKeepsTheLegacyRuleWithoutACatalog(t *testing.T) {
|
|
t.Parallel()
|
|
states := map[string]runtimeRaidState{
|
|
"no config": {Lifecycle: runtimeRaidLifecycleState{Phase: runtimeRaidDungeonPhaseOne}},
|
|
"phase zero": {AntonConfig: &raid.AntonConfig{}, Lifecycle: runtimeRaidLifecycleState{Phase: 0}},
|
|
"phase past the catalog": {
|
|
AntonConfig: &raid.AntonConfig{Phases: []raid.AntonPhase{{MinimalDungeonClearCount: 1}}},
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: runtimeRaidDungeonPhaseTwo},
|
|
},
|
|
"single mode without a single catalog": {
|
|
TailFlag: 1,
|
|
AntonConfig: &raid.AntonConfig{Phases: []raid.AntonPhase{{MinimalDungeonClearCount: 1}}},
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: runtimeRaidDungeonPhaseOne},
|
|
},
|
|
}
|
|
for name, state := range states {
|
|
state := state
|
|
t.Run(name, func(t *testing.T) {
|
|
state.Lifecycle.PhaseClearCounts = map[uint16]uint32{19: 0}
|
|
if runtimeRaidMemberClearedGateLocked(state, 19) {
|
|
t.Fatal("a missing declaration was read as an unconditional phase")
|
|
}
|
|
state.Lifecycle.PhaseClearCounts[19] = 1
|
|
if !runtimeRaidMemberClearedGateLocked(state, 19) {
|
|
t.Fatal("the pre-PVF rule no longer pays a member that cleared a gate")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRuntimeRaidAntonClearRequirementIsBitForBitUnchanged is the A/B that makes the
|
|
// Luke fix safe. Every Anton phase declares [MINIMAL DUNGEON CLEAR COUNT] 1:
|
|
//
|
|
// - team phases cannot declare anything else, because antonParsePhaseWithPartyMax
|
|
// demands a positive value (anton_pvf.go:586);
|
|
// - the single phase's PVF block omits the section entirely and is defaulted to 1
|
|
// (anton_pvf.go:503).
|
|
//
|
|
// The clear counter is a uint32, so `count >= 1` and the pre-PVF `count > 0` are the
|
|
// same predicate. This sweep proves the new requirement cannot diverge for any count
|
|
// a phase can register, including the counts the display cap can hold.
|
|
func TestRuntimeRaidAntonClearRequirementIsBitForBitUnchanged(t *testing.T) {
|
|
t.Parallel()
|
|
config := antonClearRequirementConfig()
|
|
for _, phase := range []byte{runtimeRaidDungeonPhaseOne, runtimeRaidDungeonPhaseTwo} {
|
|
for tail := byte(0); tail <= 1; tail++ {
|
|
state := runtimeRaidState{
|
|
RaidMode: runtimeRaidOwnerAnton,
|
|
TailFlag: tail,
|
|
AntonConfig: &config,
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: phase},
|
|
}
|
|
for cleared := uint32(0); cleared <= 8; cleared++ {
|
|
state.Lifecycle.PhaseClearCounts = map[uint16]uint32{19: cleared}
|
|
want := cleared > 0
|
|
if got := runtimeRaidMemberClearedGateLocked(state, 19); got != want {
|
|
t.Fatalf("phase=%d tail=%d cleared=%d eligible=%t want %t",
|
|
phase, tail, cleared, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRuntimeRaidLukePhaseTwoRewardNeedsTwoRegisteredClears drives the requirement
|
|
// through the real registration path instead of a hand-written counter, and pins the
|
|
// two ends of the official rule: Luke phase two pays only once the 地下城通关 column
|
|
// reaches 2, while Luke phase one is unconditional.
|
|
func TestRuntimeRaidLukePhaseTwoRewardNeedsTwoRegisteredClears(t *testing.T) {
|
|
t.Parallel()
|
|
lukeConfig := lukeClearRequirementConfig()
|
|
member := runtimeRaidMemberState{CharID: 19, Name: "leader", GroupIndex: 1, SlotOrder: 0}
|
|
|
|
phaseTwo := &runtimeRaidState{
|
|
RaidMode: runtimeRaidOwnerLuke,
|
|
LukeConfig: &raid.LukeConfig{Raid: &lukeConfig},
|
|
Members: []runtimeRaidMemberState{member},
|
|
FrozenMembers: []runtimeRaidMemberState{member},
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: runtimeRaidDungeonPhaseTwo},
|
|
}
|
|
runtimeRaidNotePhaseClearLocked(phaseTwo, 19, runtimeRaidDungeonPhaseTwo)
|
|
if counts := phaseTwo.Lifecycle.PhaseClearCounts[19]; counts != 1 {
|
|
t.Fatalf("registered clears=%d want 1", counts)
|
|
}
|
|
if runtimeRaidMemberClearedGateLocked(*phaseTwo, 19) {
|
|
t.Fatal("one registered clear already paid the card")
|
|
}
|
|
runtimeRaidNotePhaseClearLocked(phaseTwo, 19, runtimeRaidDungeonPhaseTwo)
|
|
if counts := phaseTwo.Lifecycle.PhaseClearCounts[19]; counts != 2 {
|
|
t.Fatalf("registered clears=%d want 2 (capped at the phase requirement)", counts)
|
|
}
|
|
if !runtimeRaidMemberClearedGateLocked(*phaseTwo, 19) {
|
|
t.Fatal("two registered clears did not pay the card")
|
|
}
|
|
|
|
// Phase one declares 0, which is the official 「1阶段:无条件」: the roster is
|
|
// paid without registering a single clear.
|
|
phaseOne := &runtimeRaidState{
|
|
RaidMode: runtimeRaidOwnerLuke,
|
|
LukeConfig: &raid.LukeConfig{Raid: &lukeConfig},
|
|
Members: []runtimeRaidMemberState{member},
|
|
FrozenMembers: []runtimeRaidMemberState{member},
|
|
Lifecycle: runtimeRaidLifecycleState{Phase: runtimeRaidDungeonPhaseOne},
|
|
}
|
|
if !runtimeRaidMemberClearedGateLocked(*phaseOne, 19) {
|
|
t.Fatal("Luke phase one is unconditional and must pay a member with no clear")
|
|
}
|
|
}
|