服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
789 lines
24 KiB
Go
789 lines
24 KiB
Go
package raid
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestLoadAntonConfigReadsOnlyTargetAndPreservesSourceOrder(t *testing.T) {
|
|
source := &antonRecordingSource{text: antonTestDocument()}
|
|
config, err := LoadAntonConfig(source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(source.paths, []string{AntonPVFPath}) {
|
|
t.Fatalf("read paths=%v want only %q", source.paths, AntonPVFPath)
|
|
}
|
|
|
|
want := &AntonConfig{
|
|
PhaseMax: 2,
|
|
MemberMax: 20,
|
|
StartMinimumMembers: 10,
|
|
SingleStartMinimumMembers: 1,
|
|
StartDelaySeconds: 3,
|
|
DisconnMemberWaitTime: 300,
|
|
PhaseTimeoutSeconds: []int{2400, 2400},
|
|
PhaseBreakSeconds: 600,
|
|
MovieEndSeconds: []int{20, 21, 22, 23},
|
|
EntryCost: AntonEntryCost{ItemID: 10096296, Count: 1},
|
|
WeeklyEnterCount: [2]int{30, 3},
|
|
Phases: []AntonPhase{
|
|
{
|
|
Name: "phase one", MinimalDungeonClearCount: 1,
|
|
Rewards: []AntonStateReward{
|
|
{Kind: "party_card", Rank: -1, Weight: 100, DefinitionItemID: 10094735, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 50, DefinitionItemID: 10157773, Flag: 1},
|
|
},
|
|
Dungeons: []AntonDungeonInfo{
|
|
{Index: 210, InitState: AntonDungeonOpen, HP: 4, PartyMax: 5},
|
|
{Index: 211, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
},
|
|
RankConditions: antonTestRankConditions(),
|
|
},
|
|
{
|
|
Name: "phase two", MinimalDungeonClearCount: 1,
|
|
Rewards: []AntonStateReward{
|
|
{Kind: "party_card", Rank: -1, Weight: 100, DefinitionItemID: 10157778, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 775, DefinitionItemID: 10161936, Flag: 1},
|
|
},
|
|
Dungeons: []AntonDungeonInfo{
|
|
{Index: 218, InitState: AntonDungeonOpen, HP: 1, PartyMax: 5},
|
|
{Index: 220, InitState: AntonDungeonHide, HP: 5, PartyMax: 5},
|
|
},
|
|
RankConditions: antonTestRankConditions(),
|
|
},
|
|
},
|
|
SingleMode: &AntonSingleModeConfig{
|
|
Name: "single phase", TimeLimitSeconds: 2100,
|
|
Phase: AntonPhase{
|
|
Name: "single phase", MinimalDungeonClearCount: 1,
|
|
Rewards: []AntonStateReward{{Kind: "party_card", Rank: -1, Weight: 100, DefinitionItemID: 10094735, Flag: 0}},
|
|
Dungeons: []AntonDungeonInfo{{Index: 9999, InitState: AntonDungeonOpen, HP: 1, PartyMax: 0}},
|
|
RankConditions: antonTestRankConditions(),
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(config, want) {
|
|
t.Fatalf("config=%+v\nwant=%+v", config, want)
|
|
}
|
|
}
|
|
|
|
func TestAntonConfigModeChoicesExposeBothModesWithoutSharingSlices(t *testing.T) {
|
|
config, err := LoadAntonConfig(&antonRecordingSource{text: antonTestDocument()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []AntonRaidModeChoice{
|
|
{
|
|
Kind: AntonRaidModeTeam, PhaseNames: []string{"phase one", "phase two"},
|
|
StartMinimumMembers: 10, TimeLimitSeconds: []int{2400, 2400},
|
|
},
|
|
{
|
|
Kind: AntonRaidModeSingle, PhaseNames: []string{"single phase"},
|
|
StartMinimumMembers: 1, TimeLimitSeconds: []int{2100},
|
|
},
|
|
}
|
|
choices := config.ModeChoices()
|
|
if !reflect.DeepEqual(choices, want) {
|
|
t.Fatalf("choices=%+v\nwant=%+v", choices, want)
|
|
}
|
|
choices[0].PhaseNames[0] = "mutated"
|
|
choices[0].TimeLimitSeconds[0] = 1
|
|
choices[1].PhaseNames[0] = "mutated"
|
|
choices[1].TimeLimitSeconds[0] = 1
|
|
if !reflect.DeepEqual(config.ModeChoices(), want) {
|
|
t.Fatalf("mode choices share mutable slices with caller or config: %+v", config.ModeChoices())
|
|
}
|
|
var nilConfig *AntonConfig
|
|
if choices := nilConfig.ModeChoices(); choices != nil {
|
|
t.Fatalf("nil config choices=%+v want nil", choices)
|
|
}
|
|
}
|
|
|
|
func TestLoadAntonConfigRejectsNilSourceAndPropagatesTargetReadFailure(t *testing.T) {
|
|
if _, err := LoadAntonConfig(nil); !errors.Is(err, ErrAntonPVFSourceRequired) {
|
|
t.Fatalf("nil source error=%v", err)
|
|
}
|
|
source := &antonRecordingSource{err: os.ErrNotExist}
|
|
if _, err := LoadAntonConfig(source); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("read error=%v", err)
|
|
}
|
|
if !reflect.DeepEqual(source.paths, []string{AntonPVFPath}) {
|
|
t.Fatalf("failed read paths=%v", source.paths)
|
|
}
|
|
}
|
|
|
|
func TestParseAntonConfigRejectsWrongDocumentPath(t *testing.T) {
|
|
document, err := dnfpvf.Parse("etc/raid/luke.etc", antonTestDocument())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ParseAntonConfig(document); !errors.Is(err, ErrAntonPVFDocumentInvalid) {
|
|
t.Fatalf("wrong path error=%v", err)
|
|
}
|
|
if _, err := ParseAntonConfig(nil); !errors.Is(err, ErrAntonPVFDocumentInvalid) {
|
|
t.Fatalf("nil document error=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestAntonConfigRejectsMissingDuplicateAndInvalidCurrentFields(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(string) string
|
|
want error
|
|
}{
|
|
{
|
|
name: "missing global field",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE BREAK TIME]\n600\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "duplicate global field",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE MAX]\n2", "[PHASE MAX]\n2\n[PHASE MAX]\n2", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "unsupported ordinary phase max",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE MAX]\n2", "[PHASE MAX]\n3", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "member max exceeds protocol roster",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[RAID MEMBER MAX]\n20", "[RAID MEMBER MAX]\n21", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "minimum members exceeds member max",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[RAID START MINIMUM MEMBER]\n10", "[RAID START MINIMUM MEMBER]\n21", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "phase timeout row is partial",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE TIME OVER]\n2400 2400", "[PHASE TIME OVER]\n2400", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "movie end value is nonpositive",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[MOVIE END TIME]\n20 21 22 23", "[MOVIE END TIME]\n20 21 0 23", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "entry cost block is unclosed",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[/ENTRY COST]\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "missing weekly enter count",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[WEEKLY ENTER COUNT]\n30 3\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "weekly enter count row is partial",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[WEEKLY ENTER COUNT]\n30 3", "[WEEKLY ENTER COUNT]\n30", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "weekly enter count value is nonpositive",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[WEEKLY ENTER COUNT]\n30 3", "[WEEKLY ENTER COUNT]\n30 0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "missing phase rank table",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[CONDITION FOR RANK]\n[DEAD COUNT]\n0 1 0\n[DEAD COUNT]\n2 5 1\n[/CONDITION FOR RANK]\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "rank table carries a foreign section",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[DEAD COUNT]\n0 1 0", "[COIN COUNT]\n1", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "dead count row is partial",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[DEAD COUNT]\n0 1 0", "[DEAD COUNT]\n0 1", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "dead count range is reversed",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[DEAD COUNT]\n2 5 1", "[DEAD COUNT]\n5 2 1", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "phase coin limit is nonpositive",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[MINIMAL DUNGEON CLEAR COUNT]\n1", "[PHASE COIN LIMIT]\n0\n[MINIMAL DUNGEON CLEAR COUNT]\n1", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "lockout type row is partial",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4\n[PARTY MAX]\n5", "[210 DGN HP]\n4\n[PARTY MAX]\n5\n[LOCKOUT TYPE]\n4", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "lockout type value is negative",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4\n[PARTY MAX]\n5", "[210 DGN HP]\n4\n[PARTY MAX]\n5\n[LOCKOUT TYPE]\n-1 0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "destroy object info precedes party max",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4\n[PARTY MAX]\n5", "[210 DGN HP]\n4\n[DESTROY OBJECT INFO]\n[PARTY MAX]\n5", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "destroy object info declares no group",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4\n[PARTY MAX]\n5", "[210 DGN HP]\n4\n[PARTY MAX]\n5\n[DESTROY OBJECT INFO]", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "destroy object group carries values",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4\n[PARTY MAX]\n5",
|
|
"[210 DGN HP]\n4\n[PARTY MAX]\n5\n[DESTROY OBJECT INFO]\n[BIRTH SANCTUM OBJECT]\n1", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "entry cost count is invalid",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "10096296 1", "10096296 0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "ordinary phase missing",
|
|
mutate: func(text string) string {
|
|
start := strings.Index(text, "[PHASE]\n[PHASE NAME]\n`phase two`")
|
|
end := strings.Index(text[start:], "[/PHASE]") + start + len("[/PHASE]\n")
|
|
return text[:start] + text[end:]
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "ordinary phase is unclosed",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[/PHASE]\n[PHASE]\n[PHASE NAME]\n`phase two`", "[PHASE]\n[PHASE NAME]\n`phase two`", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "phase name missing",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE NAME]\n`phase one`\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "reward block missing close",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[/CLEAR REWARD ITEM]\n[MINIMAL DUNGEON CLEAR COUNT]", "[MINIMAL DUNGEON CLEAR COUNT]", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "reward row is partial",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "`party_card` -1 100 10094735 0", "`party_card` -1 100 10094735", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "reward rank below native sentinel",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "`party_card` -1 100 10094735 0", "`party_card` -2 100 10094735 0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "reward weight is zero",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "`party_card` -1 100 10094735 0", "`party_card` -1 0 10094735 0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "reward flag is outside boolean domain",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "`party_card` -1 100 10094735 0", "`party_card` -1 100 10094735 2", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "minimum clear count exceeds dungeon count",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[MINIMAL DUNGEON CLEAR COUNT]\n1", "[MINIMAL DUNGEON CLEAR COUNT]\n3", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "dungeon repeats across phases",
|
|
mutate: func(text string) string {
|
|
text = strings.Replace(text, "[INDEX]\n218", "[INDEX]\n210", 1)
|
|
return strings.Replace(text, "[218 DGN HP]", "[210 DGN HP]", 1)
|
|
},
|
|
want: ErrAntonPVFDuplicateDungeon,
|
|
},
|
|
{
|
|
name: "dungeon index is zero",
|
|
mutate: func(text string) string {
|
|
text = strings.Replace(text, "[INDEX]\n210", "[INDEX]\n0", 1)
|
|
return strings.Replace(text, "[210 DGN HP]", "[0 DGN HP]", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "dungeon state is unknown",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[INIT STATE]\n`open`", "[INIT STATE]\n`ready`", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "dungeon hp is zero",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[210 DGN HP]\n4", "[210 DGN HP]\n0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "dungeon party max exceeds raid member max",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PARTY MAX]\n5", "[PARTY MAX]\n21", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "dungeon field is duplicated",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[INDEX]\n210", "[INDEX]\n210\n[INDEX]\n210", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "dungeon fields are out of order",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[HP INFO]\n[210 DGN HP]\n4\n[PARTY MAX]\n5", "[PARTY MAX]\n5\n[HP INFO]\n[210 DGN HP]\n4", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "dungeon block has unsupported field",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PARTY MAX]\n5\n[/DUNGEON INFO]", "[PARTY MAX]\n5\n[UNKNOWN]\n1\n[/DUNGEON INFO]", 1)
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
source := &antonRecordingSource{text: test.mutate(antonTestDocument())}
|
|
_, err := LoadAntonConfig(source)
|
|
if !errors.Is(err, test.want) {
|
|
t.Fatalf("error=%v want errors.Is(%v)", err, test.want)
|
|
}
|
|
if !reflect.DeepEqual(source.paths, []string{AntonPVFPath}) {
|
|
t.Fatalf("read paths=%v", source.paths)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAntonConfigSingleModeValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(string) string
|
|
want error
|
|
}{
|
|
{
|
|
name: "missing single minimum members",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[SINGLE RAID START MINIUM MEMBER]\n1\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "missing single timeout",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[SINGLE RAID TIME OVER]\n2100\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
{
|
|
name: "nonpositive single timeout",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[SINGLE RAID TIME OVER]\n2100", "[SINGLE RAID TIME OVER]\n0", 1)
|
|
},
|
|
want: ErrAntonPVFValueInvalid,
|
|
},
|
|
{
|
|
name: "duplicate single phase",
|
|
mutate: func(text string) string {
|
|
start := strings.Index(text, "[SINGLE RAID PHASE]\n")
|
|
end := strings.Index(text[start:], "[/SINGLE RAID PHASE]") + start + len("[/SINGLE RAID PHASE]\n")
|
|
return text + text[start:end]
|
|
},
|
|
want: ErrAntonPVFSectionShape,
|
|
},
|
|
{
|
|
name: "single phase name missing",
|
|
mutate: func(text string) string {
|
|
return strings.Replace(text, "[PHASE NAME]\n`single phase`\n", "", 1)
|
|
},
|
|
want: ErrAntonPVFSectionMissing,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := LoadAntonConfig(&antonRecordingSource{text: test.mutate(antonTestDocument())})
|
|
if !errors.Is(err, test.want) {
|
|
t.Fatalf("error=%v want errors.Is(%v)", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAntonConfigWithoutSinglePhaseKeepsOrdinaryTeamMode(t *testing.T) {
|
|
text := antonTestDocument()
|
|
start := strings.Index(text, "[SINGLE RAID TIME OVER]\n")
|
|
end := strings.Index(text, "[/SINGLE RAID PHASE]") + len("[/SINGLE RAID PHASE]\n")
|
|
text = text[:start] + text[end:]
|
|
text = strings.Replace(text, "[SINGLE RAID START MINIUM MEMBER]\n1\n", "", 1)
|
|
config, err := LoadAntonConfig(&antonRecordingSource{text: text})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if config.SingleMode != nil {
|
|
t.Fatalf("single config=%+v", config)
|
|
}
|
|
if config.SingleStartMinimumMembers != 0 {
|
|
t.Fatalf("single minimum members=%d want 0", config.SingleStartMinimumMembers)
|
|
}
|
|
choices := config.ModeChoices()
|
|
if len(choices) != 1 || choices[0].Kind != AntonRaidModeTeam {
|
|
t.Fatalf("choices=%+v want team-only", choices)
|
|
}
|
|
}
|
|
|
|
func TestRealScriptPVFAntonConfigMatchesCurrentTwoPhaseCatalog(t *testing.T) {
|
|
pvfPath := os.Getenv("DNF_ANTON_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
t.Skip("set DNF_ANTON_REAL_PVF_SMOKE to verify etc/raid/anton.etc")
|
|
}
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config, err := LoadAntonConfig(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if config.PhaseMax != 2 || config.MemberMax != 20 || config.StartMinimumMembers != 10 ||
|
|
config.SingleStartMinimumMembers != 1 || config.StartDelaySeconds != 3 ||
|
|
// [DISCONN MEMBER WAIT TIME] is the drop grace the raid runtime reads
|
|
// (dnfbridge runtimeRaidDisconnectGraceForState). Pinned here because the
|
|
// runtime used to carry the same 300 as a bare constant: if this ever
|
|
// stops being 300, the grace silently changes with it.
|
|
config.DisconnMemberWaitTime != 300 ||
|
|
config.PhaseBreakSeconds != 600 || config.EntryCost != (AntonEntryCost{ItemID: 10096296, Count: 1}) ||
|
|
!reflect.DeepEqual(config.PhaseTimeoutSeconds, []int{2400, 2400}) ||
|
|
!reflect.DeepEqual(config.MovieEndSeconds, []int{20, 20, 20, 20}) ||
|
|
config.WeeklyEnterCount != [2]int{30, 3} {
|
|
t.Fatalf("real global config=%+v", config)
|
|
}
|
|
|
|
// Both ordinary phases and the single-mode phase share one rank table, and
|
|
// no Anton phase declares a revive-coin cap.
|
|
wantRank := []AntonRankCondition{
|
|
{MinimumDeaths: 0, MaximumDeaths: 1, Rank: 0},
|
|
{MinimumDeaths: 2, MaximumDeaths: 5, Rank: 1},
|
|
{MinimumDeaths: 6, MaximumDeaths: 9, Rank: 2},
|
|
{MinimumDeaths: 10, MaximumDeaths: 14, Rank: 3},
|
|
}
|
|
wantPhases := []AntonPhase{
|
|
{
|
|
Name: "\u963b\u622a\u5b89\u5f92\u6069", MinimalDungeonClearCount: 1,
|
|
Rewards: []AntonStateReward{
|
|
{Kind: "party_card", Rank: -1, Weight: 100, DefinitionItemID: 10094735, Flag: 0},
|
|
{Kind: "pcroom_card", Rank: -1, Weight: 100, DefinitionItemID: 10094733, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 50, DefinitionItemID: 10157773, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 1, DefinitionItemID: 10157775, Flag: 1},
|
|
{Kind: "squad_item", Rank: -1, Weight: 1, DefinitionItemID: 10157776, Flag: 1},
|
|
{Kind: "squad_item", Rank: -1, Weight: 48, DefinitionItemID: 10157777, Flag: 1},
|
|
},
|
|
Dungeons: []AntonDungeonInfo{
|
|
{Index: 210, InitState: AntonDungeonOpen, HP: 4, PartyMax: 5},
|
|
{Index: 211, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 212, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 213, InitState: AntonDungeonHide, HP: 2, PartyMax: 5},
|
|
{Index: 214, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 215, InitState: AntonDungeonHide, HP: 2, PartyMax: 5},
|
|
{Index: 216, InitState: AntonDungeonHide, HP: 1, PartyMax: 1},
|
|
},
|
|
RankConditions: wantRank,
|
|
},
|
|
{
|
|
Name: "\u706d\u6740\u5b89\u5f92\u6069", MinimalDungeonClearCount: 1,
|
|
Rewards: []AntonStateReward{
|
|
{Kind: "party_card", Rank: -1, Weight: 100, DefinitionItemID: 10157778, Flag: 0},
|
|
{Kind: "pcroom_card", Rank: -1, Weight: 100, DefinitionItemID: 10094733, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 775, DefinitionItemID: 10161936, Flag: 0},
|
|
{Kind: "squad_item", Rank: -1, Weight: 145, DefinitionItemID: 10157783, Flag: 1},
|
|
{Kind: "squad_item", Rank: -1, Weight: 50, DefinitionItemID: 10157784, Flag: 1},
|
|
{Kind: "squad_item", Rank: -1, Weight: 25, DefinitionItemID: 10157785, Flag: 1},
|
|
{Kind: "squad_item", Rank: -1, Weight: 5, DefinitionItemID: 10157786, Flag: 1},
|
|
},
|
|
Dungeons: []AntonDungeonInfo{
|
|
{Index: 218, InitState: AntonDungeonOpen, HP: 1, PartyMax: 5},
|
|
{Index: 219, InitState: AntonDungeonOpen, HP: 1, PartyMax: 5},
|
|
{Index: 220, InitState: AntonDungeonHide, HP: 5, PartyMax: 5},
|
|
{Index: 221, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 222, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 223, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
{Index: 224, InitState: AntonDungeonHide, HP: 1, PartyMax: 5},
|
|
},
|
|
RankConditions: wantRank,
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(config.Phases, wantPhases) {
|
|
t.Fatalf("real phases=%+v\nwant=%+v", config.Phases, wantPhases)
|
|
}
|
|
}
|
|
|
|
func TestRealScriptPVFAntonConfigExposesNativeModeChoices(t *testing.T) {
|
|
pvfPath := os.Getenv("DNF_ANTON_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
t.Skip("set DNF_ANTON_REAL_PVF_SMOKE to verify native Anton mode choices")
|
|
}
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
config, err := LoadAntonConfig(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if config.SingleMode == nil || config.SingleMode.Name != "\u5355\u4eba\u5b89\u5f92\u6069" || config.SingleMode.TimeLimitSeconds != 2100 {
|
|
t.Fatalf("single mode=%+v", config.SingleMode)
|
|
}
|
|
choices := config.ModeChoices()
|
|
if len(choices) != 2 {
|
|
t.Fatalf("mode choices=%+v want two native modes", choices)
|
|
}
|
|
if choices[0].Kind != AntonRaidModeTeam || choices[0].StartMinimumMembers != 10 ||
|
|
!reflect.DeepEqual(choices[0].PhaseNames, []string{"\u963b\u622a\u5b89\u5f92\u6069", "\u706d\u6740\u5b89\u5f92\u6069"}) ||
|
|
!reflect.DeepEqual(choices[0].TimeLimitSeconds, []int{2400, 2400}) {
|
|
t.Fatalf("team choice=%+v", choices[0])
|
|
}
|
|
if choices[1].Kind != AntonRaidModeSingle || choices[1].StartMinimumMembers != 1 ||
|
|
!reflect.DeepEqual(choices[1].PhaseNames, []string{config.SingleMode.Name}) ||
|
|
!reflect.DeepEqual(choices[1].TimeLimitSeconds, []int{2100}) {
|
|
t.Fatalf("single choice=%+v", choices[1])
|
|
}
|
|
}
|
|
|
|
type antonRecordingSource struct {
|
|
text string
|
|
err error
|
|
paths []string
|
|
}
|
|
|
|
func (source *antonRecordingSource) ReadText(path string) (string, error) {
|
|
source.paths = append(source.paths, path)
|
|
if path != AntonPVFPath {
|
|
return "", fmt.Errorf("unexpected path %q", path)
|
|
}
|
|
if source.err != nil {
|
|
return "", source.err
|
|
}
|
|
return source.text, nil
|
|
}
|
|
|
|
// antonTestRankConditions mirrors the [CONDITION FOR RANK] block every phase of
|
|
// antonTestDocument declares.
|
|
func antonTestRankConditions() []AntonRankCondition {
|
|
return []AntonRankCondition{
|
|
{MinimumDeaths: 0, MaximumDeaths: 1, Rank: 0},
|
|
{MinimumDeaths: 2, MaximumDeaths: 5, Rank: 1},
|
|
}
|
|
}
|
|
|
|
func antonTestDocument() string {
|
|
return `[PHASE MAX]
|
|
2
|
|
[RAID MEMBER MAX]
|
|
20
|
|
[RAID START MINIMUM MEMBER]
|
|
10
|
|
[SINGLE RAID START MINIUM MEMBER]
|
|
1
|
|
[START DELAY TIME]
|
|
3
|
|
[PHASE TIME OVER]
|
|
2400 2400
|
|
[PHASE BREAK TIME]
|
|
600
|
|
[MOVIE END TIME]
|
|
20 21 22 23
|
|
[DISCONN MEMBER WAIT TIME]
|
|
300
|
|
[ENTRY COST]
|
|
10096296 1
|
|
[/ENTRY COST]
|
|
[WEEKLY ENTER COUNT]
|
|
30 3
|
|
[PHASE]
|
|
[PHASE NAME]
|
|
` + "`phase one`" + `
|
|
[CONDITION FOR RANK]
|
|
[DEAD COUNT]
|
|
0 1 0
|
|
[DEAD COUNT]
|
|
2 5 1
|
|
[/CONDITION FOR RANK]
|
|
[CLEAR REWARD ITEM]
|
|
[STATE REWARD]
|
|
` + "`party_card`" + ` -1 100 10094735 0
|
|
[STATE REWARD]
|
|
` + "`squad_item`" + ` -1 50 10157773 1
|
|
[/CLEAR REWARD ITEM]
|
|
[MINIMAL DUNGEON CLEAR COUNT]
|
|
1
|
|
[DUNGEON INFO]
|
|
[INDEX]
|
|
210
|
|
[INIT STATE]
|
|
` + "`open`" + `
|
|
[HP INFO]
|
|
[210 DGN HP]
|
|
4
|
|
[PARTY MAX]
|
|
5
|
|
[/DUNGEON INFO]
|
|
[DUNGEON INFO]
|
|
[INDEX]
|
|
211
|
|
[INIT STATE]
|
|
` + "`hide`" + `
|
|
[HP INFO]
|
|
[211 DGN HP]
|
|
1
|
|
[PARTY MAX]
|
|
5
|
|
[/DUNGEON INFO]
|
|
[/PHASE]
|
|
[PHASE]
|
|
[PHASE NAME]
|
|
` + "`phase two`" + `
|
|
[CONDITION FOR RANK]
|
|
[DEAD COUNT]
|
|
0 1 0
|
|
[DEAD COUNT]
|
|
2 5 1
|
|
[/CONDITION FOR RANK]
|
|
[CLEAR REWARD ITEM]
|
|
[STATE REWARD]
|
|
` + "`party_card`" + ` -1 100 10157778 0
|
|
[STATE REWARD]
|
|
` + "`squad_item`" + ` -1 775 10161936 1
|
|
[/CLEAR REWARD ITEM]
|
|
[MINIMAL DUNGEON CLEAR COUNT]
|
|
1
|
|
[DUNGEON INFO]
|
|
[INDEX]
|
|
218
|
|
[INIT STATE]
|
|
` + "`open`" + `
|
|
[HP INFO]
|
|
[218 DGN HP]
|
|
1
|
|
[PARTY MAX]
|
|
5
|
|
[/DUNGEON INFO]
|
|
[DUNGEON INFO]
|
|
[INDEX]
|
|
220
|
|
[INIT STATE]
|
|
` + "`hide`" + `
|
|
[HP INFO]
|
|
[220 DGN HP]
|
|
5
|
|
[PARTY MAX]
|
|
5
|
|
[/DUNGEON INFO]
|
|
[/PHASE]
|
|
[SINGLE RAID TIME OVER]
|
|
2100
|
|
[SINGLE RAID PHASE]
|
|
[PHASE NAME]
|
|
` + "`single phase`" + `
|
|
[CONDITION FOR RANK]
|
|
[DEAD COUNT]
|
|
0 1 0
|
|
[DEAD COUNT]
|
|
2 5 1
|
|
[/CONDITION FOR RANK]
|
|
[CLEAR REWARD ITEM]
|
|
[STATE REWARD]
|
|
` + "`party_card`" + ` -1 100 10094735 0
|
|
[/CLEAR REWARD ITEM]
|
|
[DUNGEON INFO]
|
|
[INDEX]
|
|
9999
|
|
[INIT STATE]
|
|
` + "`open`" + `
|
|
[HP INFO]
|
|
[9999 DGN HP]
|
|
1
|
|
[PARTY MAX]
|
|
0
|
|
[/DUNGEON INFO]
|
|
[/SINGLE RAID PHASE]
|
|
`
|
|
}
|