DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,788 @@
|
||||
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]
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package raid
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
// RaidAvailabilityMsgID is the current class-0 notification registered by
|
||||
// DNF.exe for the three daily team-raid availability rows. It is written as
|
||||
// hexadecimal here deliberately: the generated command enum's decimal 517
|
||||
// entry is a different legacy command.
|
||||
RaidAvailabilityMsgID uint16 = 0x0517
|
||||
|
||||
RaidAvailabilityRowCount = 3
|
||||
)
|
||||
|
||||
var errRaidAvailabilityRowCount = errors.New("raid availability requires exactly three rows")
|
||||
|
||||
// RaidAvailabilityRow is one five-byte row consumed by the current client:
|
||||
// key, used count, daily limit, and two opaque bytes.
|
||||
type RaidAvailabilityRow struct {
|
||||
Key byte
|
||||
Used byte
|
||||
Limit byte
|
||||
Field3 byte
|
||||
Field4 byte
|
||||
}
|
||||
|
||||
// BuildRaidAvailabilityBody builds the exact fixed-width 0x0517 body.
|
||||
// DNF.exe reads three rows unconditionally, so accepting another count would
|
||||
// shift the following packet boundary or leave the client waiting for bytes.
|
||||
func BuildRaidAvailabilityBody(rows []RaidAvailabilityRow) ([]byte, error) {
|
||||
if len(rows) != RaidAvailabilityRowCount {
|
||||
return nil, errRaidAvailabilityRowCount
|
||||
}
|
||||
body := make([]byte, 0, RaidAvailabilityRowCount*5)
|
||||
for _, row := range rows {
|
||||
body = append(body, row.Key, row.Used, row.Limit, row.Field3, row.Field4)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// BuildNativeRaidAvailabilityBody exposes the current local 90CN defaults:
|
||||
// all three registered team-raid keys are available once today and have not
|
||||
// been entered yet. The opaque fields remain zero until a client meaning is
|
||||
// proved independently.
|
||||
func BuildNativeRaidAvailabilityBody() []byte {
|
||||
body, err := BuildRaidAvailabilityBody([]RaidAvailabilityRow{
|
||||
{Key: 1, Limit: 1},
|
||||
{Key: 2, Limit: 1},
|
||||
{Key: 3, Limit: 1},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildNativeRaidAvailabilityBodyUsesCurrentThreeRowLayout(t *testing.T) {
|
||||
got := BuildNativeRaidAvailabilityBody()
|
||||
want := []byte{
|
||||
0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0x02, 0x00, 0x01, 0x00, 0x00,
|
||||
0x03, 0x00, 0x01, 0x00, 0x00,
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("native raid availability body=%x, want %x", got, want)
|
||||
}
|
||||
if len(got) != RaidAvailabilityRowCount*5 {
|
||||
t.Fatalf("native raid availability length=%d, want %d", len(got), RaidAvailabilityRowCount*5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidAvailabilityBodyRejectsWrongRowCount(t *testing.T) {
|
||||
if _, err := BuildRaidAvailabilityBody(nil); err != errRaidAvailabilityRowCount {
|
||||
t.Fatalf("nil rows error=%v, want %v", err, errRaidAvailabilityRowCount)
|
||||
}
|
||||
if _, err := BuildRaidAvailabilityBody(make([]RaidAvailabilityRow, RaidAvailabilityRowCount+1)); err != errRaidAvailabilityRowCount {
|
||||
t.Fatalf("extra rows error=%v, want %v", err, errRaidAvailabilityRowCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// 本文件按当前 DNF.exe 的读取顺序构造攻坚队通知包体。
|
||||
package raid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const (
|
||||
// Current DNF.exe notification ids. The complete raid object and mode=3
|
||||
// roster refresh share the 0x024F handler; live server traces also pin the
|
||||
// timer pair to 0x0254/0x0255. Keep these IDs aligned with the current EXE,
|
||||
// rather than the older one-byte prepare packet layout.
|
||||
RaidSymbolMsgID uint16 = 0x0247
|
||||
RaidParticipationMsgID uint16 = 0x0248
|
||||
RaidDungeonStateMsgID uint16 = 0x0249
|
||||
RaidDungeonChangeMsgID uint16 = 0x024a
|
||||
RaidStateMsgID uint16 = 0x024b
|
||||
RaidMessageMsgID uint16 = 0x024c
|
||||
RaidMemberRefreshMsgID uint16 = 0x024f
|
||||
RaidObjectMsgID uint16 = RaidMemberRefreshMsgID
|
||||
RaidWaitingListMsgID uint16 = 0x0250
|
||||
// Current EXE sub_1D59170 is the 0x0251 waiting-row delta reader. It
|
||||
// consumes op:u8, member:u16 and, for op=0, state:u8.
|
||||
RaidWaitingChangeMsgID uint16 = 0x0251
|
||||
// Current EXE sub_1D67770 is the bodyless 0x0252 preparation notifier. It
|
||||
// constructs the native Raid preparation panel after state/timer data has
|
||||
// been staged. 0x0253 belongs to the unrelated user-info structure path.
|
||||
RaidPrepareStartMsgID uint16 = 0x0252
|
||||
RaidTimerMsgID uint16 = 0x0254
|
||||
RaidRemainTimeMsgID uint16 = 0x0255
|
||||
RaidEntryCostMsgID uint16 = 0x0256
|
||||
// RaidInfoMsgID is the current class-0 response to C2S REQUEST_RAID_INFO.
|
||||
RaidInfoMsgID uint16 = 1359
|
||||
// RaidDirectoryReadyMsgID is the current EXE's team-channel directory
|
||||
// initialization notification. Its registration at sub_344AAD8 binds
|
||||
// opcode 0x04CC to sub_34463B0, which consumes one reserved byte and calls
|
||||
// sub_17B1890 to initialize the public-directory cooldown timestamp.
|
||||
RaidDirectoryReadyMsgID uint16 = 1228
|
||||
// Current EXE registration in sub_2262A00 maps these consecutive IDs to
|
||||
// three different handlers: 0x0257 is the bodyless movie transition,
|
||||
// 0x0258 consumes the nested reward list, and 0x0259 consumes the fixed
|
||||
// ten-byte Raid result. 0x025A is an unrelated nine-byte notification.
|
||||
RaidMovieMsgID uint16 = 0x0257
|
||||
RaidRewardListMsgID uint16 = 0x0258
|
||||
RaidResultMsgID uint16 = 0x0259
|
||||
RaidBuffMsgID uint16 = 0x0279
|
||||
RaidMonsterStateMsgID uint16 = 0x027a
|
||||
// The current EXE routes the raid object, its roster, and its removal through
|
||||
// one 0x024F handler that reads u32 raid_key then u32 operation. The 86JP C#
|
||||
// reference used the same layout on its RAID_MODIFY opcode with 0=create/full
|
||||
// refresh, 1=remove, 2=object update and 3=member list, so 0 and 3 were the
|
||||
// only operations this server sent until now.
|
||||
RaidMemberRefreshMode0 uint32 = 0
|
||||
RaidMemberRefreshMode1 uint32 = 1
|
||||
RaidMemberRefreshMode3 uint32 = 3
|
||||
MaxAttackPartyMembers = 20
|
||||
|
||||
raidMemberNameMaxBytes = 256
|
||||
// raidTitleMaxBytes is the title reader's own capacity (sub_3457D20 is
|
||||
// called with 0x100 for the object title).
|
||||
raidTitleMaxBytes = 0x100
|
||||
maxRaidByteCount = 255
|
||||
)
|
||||
|
||||
// MemberRecord 是 sub_17B6EF0 读取的一条攻坚队成员记录。
|
||||
type MemberRecord struct {
|
||||
CharKey uint16
|
||||
Field4 byte
|
||||
Name string
|
||||
// RawNameBytes is the DSTR payload in the encoding expected by the
|
||||
// current client member-info handler (GB18030 for character names). It
|
||||
// takes precedence over Name and the legacy UTF-16 field.
|
||||
RawNameBytes []byte
|
||||
// RawNameUTF16LE is retained for older callers and captures the native
|
||||
// compatibility record used by the previous client path.
|
||||
RawNameUTF16LE []byte
|
||||
// Field40 is the base job. Current EXE sub_1B4ED60 passes it as the first
|
||||
// argument to the native profession-name resolver sub_249D7F0.
|
||||
Field40 byte
|
||||
// Field44 is the persisted grow_type byte. The client splits its low nibble
|
||||
// into the class branch and bits 4..6 into the awakening stage.
|
||||
Field44 byte
|
||||
GroupIndex byte
|
||||
SlotOrder byte
|
||||
Field48 uint32
|
||||
Field52 byte
|
||||
Field53 byte
|
||||
Field56 byte
|
||||
Field60 uint32
|
||||
Field64 uint16
|
||||
Field66BoolSource uint32
|
||||
}
|
||||
|
||||
// MemberRefresh 保存 0x24F mode=3 的成员名单刷新数据。
|
||||
type MemberRefresh struct {
|
||||
RaidKey uint32
|
||||
Members []MemberRecord
|
||||
}
|
||||
|
||||
// RaidObjectMode0 is the complete object consumed by current EXE
|
||||
// sub_1D71CA0 -> sub_17B8910 -> sub_17BF120 during initial creation.
|
||||
// The native 90CN creation trace contains the leader record, a byte-sized
|
||||
// member count, and that many compatibility member records. The client uses
|
||||
// the latter to populate the initial member-info rows before the mode=3 roster
|
||||
// refresh arrives, so keep them on the wire even though mode=3 is the canonical
|
||||
// subsequent refresh path.
|
||||
type RaidObjectMode0 struct {
|
||||
RaidKey uint32
|
||||
// TitleBytes is the raid's own name as a raw dstr payload in the client's
|
||||
// narrow text encoding (GB18030), byte-for-byte what the client sent in the
|
||||
// 664/670 request body. It must NOT be converted to UTF-16: the client reads
|
||||
// this field with sub_3457D20(dst, 0x100, GetString(0x29D)), which is the
|
||||
// same length-prefixed narrow-string reader every other dstr on this wire
|
||||
// uses, and it widens the payload one byte at a time (0x3457D20 -> 0x34D9CA0).
|
||||
//
|
||||
// Sending UTF-16LE here is the "纯数字攻坚队名只显示第一位 / 汉字后跟数字乱码"
|
||||
// bug: the reader consumes the payload as local-code-page bytes, so "1111"
|
||||
// (31 00 31 00 ...) stops at the first NUL after "1", and "礼白1"
|
||||
// (3C 79 7D 76 31 00) renders as "<y}v1". Verified against the 86 reference
|
||||
// server, which carries the request's own bytes through WriteRawDstr
|
||||
// unchanged (RaidPacketBuilder.WriteRaidObject -> GamePacketWriter.WriteRawDstr).
|
||||
TitleBytes []byte
|
||||
ObjectField24 byte
|
||||
ObjectField28 byte
|
||||
ObjectField30 byte
|
||||
ObjectField34 uint32
|
||||
ObjectField94 byte
|
||||
Leader MemberRecord
|
||||
Members []MemberRecord
|
||||
}
|
||||
|
||||
// EntryCostRow is one current 0x0256 row. The current EXE consumes a member
|
||||
// key, a one-byte material-present field, and a u16 value. ItemID/Value32 are
|
||||
// retained only as a compatibility bridge for older callers.
|
||||
type EntryCostRow struct {
|
||||
MemberKey uint16
|
||||
// Field/Value are retained for source compatibility with older callers.
|
||||
Field byte
|
||||
Value uint16
|
||||
ItemID uint32
|
||||
Value32 uint32
|
||||
}
|
||||
|
||||
type WaitingRow struct {
|
||||
MemberKey uint16
|
||||
State byte
|
||||
}
|
||||
|
||||
// RaidSymbolRow is one 0x0247 key/value row.
|
||||
type RaidSymbolRow struct {
|
||||
Key uint32
|
||||
Value uint32
|
||||
}
|
||||
|
||||
// RaidParticipationRow is one native 0x0248 dungeon participation row.
|
||||
// The current client reads a byte-sized member count followed by that many
|
||||
// u16 member keys. MemberKeys/Members are alternative source representations.
|
||||
type RaidParticipationRow struct {
|
||||
DungeonID uint32
|
||||
Op uint32
|
||||
MemberKeys []uint32
|
||||
// Deprecated wire shape retained for source compatibility.
|
||||
Field0 byte
|
||||
Members []uint16
|
||||
}
|
||||
|
||||
// RaidDungeonStateRow is one dungeon/state pair in a 0x0249 snapshot.
|
||||
type RaidDungeonStateRow struct {
|
||||
DungeonID uint32
|
||||
State uint32
|
||||
}
|
||||
|
||||
// RaidDungeonStateSnapshot is the current EXE's 0x0249 snapshot:
|
||||
// u8 unknown0, u8 count, repeat {u32 dungeonID,u8 state}, u32 tail.
|
||||
type RaidDungeonStateSnapshot struct {
|
||||
Unknown0 uint32
|
||||
Rows []RaidDungeonStateRow
|
||||
TrailingValue uint32
|
||||
// Deprecated compatibility field. It is folded into Unknown0 only when
|
||||
// callers still use the old one-byte initializer.
|
||||
Field0 byte
|
||||
}
|
||||
|
||||
// RaidDungeonChange is the complete fixed-width 0x024A body.
|
||||
type RaidDungeonChange struct {
|
||||
DungeonID uint32
|
||||
State byte
|
||||
Argument byte
|
||||
}
|
||||
|
||||
// RaidState is the current 0x024B body: state:u8, phase-index:u8.
|
||||
type RaidState struct {
|
||||
State byte
|
||||
Argument int8
|
||||
}
|
||||
|
||||
// RaidTimer is the complete fixed-width 0x0254 body.
|
||||
type RaidTimer struct {
|
||||
TimerType byte
|
||||
Value0 uint32
|
||||
Value1 uint32
|
||||
Value2 uint32
|
||||
}
|
||||
|
||||
// RaidRemainTime is the complete fixed-width 0x0255 body.
|
||||
type RaidRemainTime struct {
|
||||
TimerType byte
|
||||
Seconds uint32
|
||||
}
|
||||
|
||||
// RaidRewardEntry is one inner 0x0258 reward-list row. Field1 and the two
|
||||
// values intentionally remain neutral until their business meanings are
|
||||
// proved independently from the current EXE.
|
||||
type RaidRewardEntry struct {
|
||||
Field1 byte
|
||||
ItemOrValue uint32
|
||||
CountOrValue uint16
|
||||
}
|
||||
|
||||
// RaidRewardGroup is one outer 0x0258 member reward group.
|
||||
type RaidRewardGroup struct {
|
||||
MemberKey uint16
|
||||
Field0 byte
|
||||
Entries []RaidRewardEntry
|
||||
}
|
||||
|
||||
// RaidRewardList is the complete variable-width 0x0258 body.
|
||||
type RaidRewardList struct {
|
||||
ResultType byte
|
||||
Groups []RaidRewardGroup
|
||||
}
|
||||
|
||||
// RaidResult is the complete fixed-width 0x0259 body. Only ResultType has a
|
||||
// confirmed semantic name; the remaining fields retain their wire identity.
|
||||
type RaidResult struct {
|
||||
ResultType byte
|
||||
Field1 byte
|
||||
Field2 uint32
|
||||
Field3 uint16
|
||||
Field4 byte
|
||||
Field5 byte
|
||||
}
|
||||
|
||||
// RaidBuffEntry is one inner 0x0279 buff row.
|
||||
type RaidBuffEntry struct {
|
||||
PartyIndex uint16
|
||||
MemberKey uint16
|
||||
Value0 uint32
|
||||
Value1 uint32
|
||||
}
|
||||
|
||||
// RaidBuffGroup is one outer 0x0279 buff group.
|
||||
type RaidBuffGroup struct {
|
||||
Field0 byte
|
||||
Entries []RaidBuffEntry
|
||||
}
|
||||
|
||||
// RaidMonsterStateGroup is one outer 0x027A group. The current EXE consumes
|
||||
// both variable lists; their business meanings remain deliberately opaque.
|
||||
type RaidMonsterStateGroup struct {
|
||||
PartyIndex uint16
|
||||
Members []uint16
|
||||
Value0 uint32
|
||||
Value1 uint32
|
||||
Values []uint32
|
||||
}
|
||||
|
||||
var errRaidMemberTooMany = errors.New("raid member count exceeds 20")
|
||||
var errRaidMemberNameInvalid = errors.New("raid member name utf16 bytes invalid")
|
||||
var errRaidMemberNameTooLong = errors.New("raid member name utf16 bytes too long")
|
||||
var errRaidByteCountOverflow = errors.New("raid byte-sized count exceeds 255")
|
||||
var errRaidWireByteOverflow = errors.New("raid wire byte value exceeds 255")
|
||||
var errRaidMemberKeyOverflow = errors.New("raid member key exceeds uint16")
|
||||
|
||||
// BuildRaidCommandSuccessBody constructs the exact successful ACK used by
|
||||
// current 658 and 667: only the common success byte is present.
|
||||
func BuildRaidCommandSuccessBody() []byte {
|
||||
return []byte{1}
|
||||
}
|
||||
|
||||
// BuildRaidInfoResponseBody returns the empty notification consumed by the
|
||||
// current DNF.exe class-0/op1359 handler sub_1D3A150 -> sub_A42AB0.
|
||||
func BuildRaidInfoResponseBody() []byte { return nil }
|
||||
|
||||
// BuildRaidDirectoryReadyBody supplies the reserved byte consumed by the
|
||||
// current DNF.exe op1228 handler before it initializes raid-directory state.
|
||||
func BuildRaidDirectoryReadyBody() []byte { return []byte{0} }
|
||||
|
||||
// BuildCreateRaidResultBody constructs class1/op664 CREATE_RAID success.
|
||||
// Current EXE sub_1D04340 consumes the common success byte followed by raid_key:u32.
|
||||
func BuildCreateRaidResultBody(raidKey uint32) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(1)
|
||||
writer.writeUint32(raidKey)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildForcedLeaveRaidResultBody constructs the class1/op665 result that tells the
|
||||
// receiver it was removed from the raid instead of leaving on its own.
|
||||
//
|
||||
// sub_1D04A00 reads u8 result and then u8 flag. A non-zero flag pushes string
|
||||
// 46791 "已经强制退出攻坚队。" and runs the forced-leave cleanup (sub_17C2640 /
|
||||
// sub_17B16D0); both branches then close windows 0x75/0x77 and tear down the
|
||||
// receiver's raid projection. The handler is entirely receiver-relative, so this
|
||||
// packet means "you are out" and must never be sent to the leader who performed
|
||||
// the kick -- doing that made the leader's own member list vanish and turned its
|
||||
// browse window back into 参加攻坚队.
|
||||
func BuildForcedLeaveRaidResultBody() []byte {
|
||||
return []byte{1, 1}
|
||||
}
|
||||
|
||||
// BuildLeaveRaidResultBody constructs class1/op665 LEAVE_RAID success.
|
||||
//
|
||||
// The second byte is NOT "the raid ended". sub_1D04A00 reads u8 result and then
|
||||
// u8 flag, and a non-zero flag pushes string 46791 "已经强制退出攻坚队。" on top of
|
||||
// the forced-leave cleanup (sub_17C2640 / sub_17B16D0). Both branches close
|
||||
// windows 0x75/0x77 and tear the receiver's raid projection down, so the flag only
|
||||
// adds the "you were removed" wording. It was named raidEnded from the earlier
|
||||
// reading and that is what made an ordinary self-leave pop the forced-removal
|
||||
// message whenever the departure emptied the raid. Pass true only when the
|
||||
// receiver really was removed against its will.
|
||||
func BuildLeaveRaidResultBody(forciblyRemoved bool) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(1)
|
||||
if forciblyRemoved {
|
||||
writer.writeByte(1)
|
||||
} else {
|
||||
writer.writeByte(0)
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidObjectRemoveBody builds 0x024F mode=1: u32 raid_key, u32 operation and
|
||||
// nothing else. It retires a raid object the client has already materialized.
|
||||
//
|
||||
// Reference: 86JP RaidPacketBuilder.BuildRaidRemove writes u32 raidId + u32 1 on
|
||||
// RAID_MODIFY, and RaidHandler.HandleLeaveRaid sends it to the leaving member
|
||||
// unconditionally and to every remaining member when the raid disbands. Without
|
||||
// an equivalent the client keeps rendering a raid it has left, which is the
|
||||
// observed "left the raid but the roster still lists me, then the window hangs"
|
||||
// symptom: every roster push here iterates the surviving member list only, so
|
||||
// the leaver was never told anything at all.
|
||||
func BuildRaidObjectRemoveBody(raidKey uint32) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(raidKey)
|
||||
writer.writeUint32(RaidMemberRefreshMode1)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidObjectMode0Body builds the initial object. Current EXE must receive
|
||||
// this notification before the successful create ACK selects RaidKey.
|
||||
func BuildRaidObjectMode0Body(object RaidObjectMode0) ([]byte, error) {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(object.RaidKey)
|
||||
writer.writeUint32(RaidMemberRefreshMode0)
|
||||
if err := writeRaidObjectBase(&writer, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRaidByteCount(len(object.Members)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// sub_17BF120 reads this member count after the leader record, then consumes
|
||||
// exactly that many compatibility member records.
|
||||
writer.writeByte(byte(len(object.Members)))
|
||||
for _, member := range object.Members {
|
||||
if err := writeMemberRecord(&writer, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// writeRaidObjectBase writes the object consumed by sub_17B8910. The same
|
||||
// object is embedded after the mode header in op591 and directly in each
|
||||
// op753 public-directory row.
|
||||
func writeRaidObjectBase(writer *packetWriter, object RaidObjectMode0) error {
|
||||
title, err := validateRaidTitleBytes(object.TitleBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer.writeUint32(object.RaidKey)
|
||||
writer.writeRawDstr(title)
|
||||
writer.writeByte(object.ObjectField24)
|
||||
writer.writeByte(object.ObjectField28)
|
||||
writer.writeByte(object.ObjectField30)
|
||||
writer.writeUint32(object.ObjectField34)
|
||||
writer.writeByte(object.ObjectField94)
|
||||
return writeMemberRecord(writer, object.Leader)
|
||||
}
|
||||
|
||||
// BuildRaidSymbolBody builds current 0x0247:
|
||||
// u8 count + repeat {u32 key,u32 value}.
|
||||
func BuildRaidSymbolBody(rows []RaidSymbolRow) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(rows)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(rows)))
|
||||
for _, row := range rows {
|
||||
writer.writeUint32(row.Key)
|
||||
writer.writeUint32(row.Value)
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidParticipationBody builds current 0x0248:
|
||||
// u8 rowCount + repeat {u32 dungeonID,u8 operation,u8 memberCount,
|
||||
// memberCount*u16 memberKey}.
|
||||
func BuildRaidParticipationBody(rows []RaidParticipationRow) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(rows)); err != nil {
|
||||
return nil, errRaidByteCountOverflow
|
||||
}
|
||||
for _, row := range rows {
|
||||
if len(row.MemberKeys) > maxRaidByteCount || len(row.Members) > maxRaidByteCount {
|
||||
return nil, errRaidByteCountOverflow
|
||||
}
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(rows)))
|
||||
for _, row := range rows {
|
||||
op := row.Op
|
||||
if op == 0 && row.Field0 != 0 {
|
||||
op = uint32(row.Field0)
|
||||
}
|
||||
if op > 0xff {
|
||||
return nil, errRaidWireByteOverflow
|
||||
}
|
||||
memberKeys := row.MemberKeys
|
||||
if len(memberKeys) == 0 && len(row.Members) != 0 {
|
||||
memberKeys = make([]uint32, len(row.Members))
|
||||
for index, memberKey := range row.Members {
|
||||
memberKeys[index] = uint32(memberKey)
|
||||
}
|
||||
}
|
||||
writer.writeUint32(row.DungeonID)
|
||||
writer.writeByte(byte(op))
|
||||
writer.writeByte(byte(len(memberKeys)))
|
||||
for _, memberKey := range memberKeys {
|
||||
if memberKey > 0xffff {
|
||||
return nil, errRaidMemberKeyOverflow
|
||||
}
|
||||
writer.writeUint16(uint16(memberKey))
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidDungeonStateBody builds current 0x0249:
|
||||
// u8 unknown0,u8 count,repeat {u32 dungeonID,u8 state},u32 trailingValue.
|
||||
func BuildRaidDungeonStateBody(snapshot RaidDungeonStateSnapshot) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(snapshot.Rows)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var writer packetWriter
|
||||
unknown0 := snapshot.Unknown0
|
||||
if unknown0 == 0 && snapshot.Field0 != 0 {
|
||||
unknown0 = uint32(snapshot.Field0)
|
||||
}
|
||||
if unknown0 > 0xff {
|
||||
return nil, errRaidWireByteOverflow
|
||||
}
|
||||
writer.writeByte(byte(unknown0))
|
||||
writer.writeByte(byte(len(snapshot.Rows)))
|
||||
for _, row := range snapshot.Rows {
|
||||
if row.State > 0xff {
|
||||
return nil, errRaidWireByteOverflow
|
||||
}
|
||||
writer.writeUint32(row.DungeonID)
|
||||
writer.writeByte(byte(row.State))
|
||||
}
|
||||
writer.writeUint32(snapshot.TrailingValue)
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidDungeonChangeBody builds current 0x024A:
|
||||
// u32 dungeonID,u8 state,u8 argument.
|
||||
func BuildRaidDungeonChangeBody(change RaidDungeonChange) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(change.DungeonID)
|
||||
writer.writeByte(change.State)
|
||||
writer.writeByte(change.Argument)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidStateBody builds current 0x024B: u8 state,u8 argument.
|
||||
func BuildRaidStateBody(state RaidState) []byte {
|
||||
return []byte{state.State, byte(state.Argument)}
|
||||
}
|
||||
|
||||
// BuildRaidTimerBody builds current 0x0254:
|
||||
// u8 timerType,u32 value0,u32 value1,u32 value2.
|
||||
func BuildRaidTimerBody(timer RaidTimer) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(timer.TimerType)
|
||||
writer.writeUint32(timer.Value0)
|
||||
writer.writeUint32(timer.Value1)
|
||||
writer.writeUint32(timer.Value2)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidRemainTimeBody builds current 0x0255: u8 timerType,u32 seconds.
|
||||
func BuildRaidRemainTimeBody(timer RaidRemainTime) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(timer.TimerType)
|
||||
writer.writeUint32(timer.Seconds)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidMovieBody builds current 0x0257, whose body is empty.
|
||||
func BuildRaidMovieBody() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// BuildRaidRewardListBody builds current 0x0258:
|
||||
// u8 resultType,u8 groupCount, then the current nested member/reward rows.
|
||||
func BuildRaidRewardListBody(rewards RaidRewardList) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(rewards.Groups)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, group := range rewards.Groups {
|
||||
if err := validateRaidByteCount(len(group.Entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(rewards.ResultType)
|
||||
writer.writeByte(byte(len(rewards.Groups)))
|
||||
for _, group := range rewards.Groups {
|
||||
writer.writeUint16(group.MemberKey)
|
||||
writer.writeByte(group.Field0)
|
||||
writer.writeByte(byte(len(group.Entries)))
|
||||
for _, entry := range group.Entries {
|
||||
writer.writeByte(entry.Field1)
|
||||
writer.writeUint32(entry.ItemOrValue)
|
||||
writer.writeUint16(entry.CountOrValue)
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidResultBody builds current 0x0259's fixed ten-byte body.
|
||||
func BuildRaidResultBody(result RaidResult) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(result.ResultType)
|
||||
writer.writeByte(result.Field1)
|
||||
writer.writeUint32(result.Field2)
|
||||
writer.writeUint16(result.Field3)
|
||||
writer.writeByte(result.Field4)
|
||||
writer.writeByte(result.Field5)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildRaidBuffBody builds current 0x0279:
|
||||
// u8 groupCount + repeat {u8 field0,u8 entryCount,repeat four-field entry}.
|
||||
func BuildRaidBuffBody(groups []RaidBuffGroup) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(groups)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, group := range groups {
|
||||
if err := validateRaidByteCount(len(group.Entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(groups)))
|
||||
for _, group := range groups {
|
||||
writer.writeByte(group.Field0)
|
||||
writer.writeByte(byte(len(group.Entries)))
|
||||
for _, entry := range group.Entries {
|
||||
writer.writeUint16(entry.PartyIndex)
|
||||
writer.writeUint16(entry.MemberKey)
|
||||
writer.writeUint32(entry.Value0)
|
||||
writer.writeUint32(entry.Value1)
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidMonsterStateBody builds current 0x027A, preserving both nested
|
||||
// byte-sized counts and every fixed-width value in native read order.
|
||||
func BuildRaidMonsterStateBody(groups []RaidMonsterStateGroup) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(groups)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, group := range groups {
|
||||
if err := validateRaidByteCount(len(group.Members)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRaidByteCount(len(group.Values)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(groups)))
|
||||
for _, group := range groups {
|
||||
writer.writeUint16(group.PartyIndex)
|
||||
writer.writeByte(byte(len(group.Members)))
|
||||
for _, memberKey := range group.Members {
|
||||
writer.writeUint16(memberKey)
|
||||
}
|
||||
writer.writeUint32(group.Value0)
|
||||
writer.writeUint32(group.Value1)
|
||||
writer.writeByte(byte(len(group.Values)))
|
||||
for _, value := range group.Values {
|
||||
writer.writeUint32(value)
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildEmptyRaidMonsterStateBody is the wire encoding of a current 0x027A
|
||||
// zero-row snapshot. It is retained for codec tests and for a future dedicated
|
||||
// raid-service owner. It must not be used as a generic initialization packet:
|
||||
// current EXE sub_1D50990 treats it as an authoritative replacement and clears
|
||||
// its native objective/monster-state container.
|
||||
func BuildEmptyRaidMonsterStateBody() []byte {
|
||||
return []byte{0}
|
||||
}
|
||||
|
||||
// BuildEmptyRaidBuffBody is the current 0x0279 empty snapshot.
|
||||
func BuildEmptyRaidBuffBody() []byte {
|
||||
return []byte{0}
|
||||
}
|
||||
|
||||
// BuildEntryCostBody builds current 0x0256: u32 count followed by
|
||||
// {u16 memberKey,u8 field,u16 value} rows.
|
||||
func BuildEntryCostBody(rows []EntryCostRow) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(uint32(len(rows)))
|
||||
for _, row := range rows {
|
||||
writer.writeUint16(row.MemberKey)
|
||||
field := row.Field
|
||||
value := row.Value
|
||||
if field == 0 && (row.ItemID != 0 || row.Value32 != 0) {
|
||||
field = 1
|
||||
if value == 0 {
|
||||
value = uint16(row.Value32)
|
||||
}
|
||||
}
|
||||
writer.writeByte(field)
|
||||
writer.writeUint16(value)
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildWaitingListBody builds current 0x0250: u16 count followed by
|
||||
// {u16 memberKey,u8 state} rows.
|
||||
func BuildWaitingListBody(rows []WaitingRow) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint16(uint16(len(rows)))
|
||||
for _, row := range rows {
|
||||
writer.writeUint16(row.MemberKey)
|
||||
writer.writeByte(row.State)
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildWaitingChangeBody builds current 0x0251. op=0 carries a state byte;
|
||||
// op=1 removes the member and therefore ends after memberKey.
|
||||
func BuildWaitingChangeBody(op byte, row WaitingRow) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(op)
|
||||
writer.writeUint16(row.MemberKey)
|
||||
if op == 0 {
|
||||
writer.writeByte(row.State)
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildMemberRefreshMode3Body 构造 0x24F mode=3:u32 raid_key, u32 mode, u8 count, repeat member。
|
||||
func BuildMemberRefreshMode3Body(refresh MemberRefresh) ([]byte, error) {
|
||||
if len(refresh.Members) > MaxAttackPartyMembers {
|
||||
return nil, errRaidMemberTooMany
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeUint32(refresh.RaidKey)
|
||||
writer.writeUint32(RaidMemberRefreshMode3)
|
||||
writer.writeByte(byte(len(refresh.Members)))
|
||||
for _, member := range refresh.Members {
|
||||
if err := writeMemberRecord(&writer, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
func writeMemberRecord(writer *packetWriter, member MemberRecord) error {
|
||||
nameBytes, err := raidWstrBytes(member)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer.writeUint16(member.CharKey)
|
||||
writer.writeByte(member.Field4)
|
||||
writer.writeRawDstr(nameBytes)
|
||||
writer.writeByte(member.Field40)
|
||||
writer.writeByte(member.Field44)
|
||||
writer.writeByte(member.GroupIndex)
|
||||
writer.writeByte(member.SlotOrder)
|
||||
writer.writeUint32(member.Field48)
|
||||
writer.writeByte(member.Field52)
|
||||
writer.writeByte(member.Field53)
|
||||
writer.writeByte(member.Field56)
|
||||
writer.writeUint32(member.Field60)
|
||||
writer.writeUint16(member.Field64)
|
||||
writer.writeUint32(member.Field66BoolSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
// raidWstrBytes picks the bytes for one member record's name dstr.
|
||||
//
|
||||
// The dstr this feeds is read by the same narrow reader as the object title
|
||||
// (sub_3457D20 -> 0x34D9CA0 widens it from the local code page), so the payload
|
||||
// MUST be client-encoding (GB18030) bytes. rawNameBytes is the only branch that
|
||||
// satisfies that, and it is the only branch production takes: every live
|
||||
// MemberRecord is built by runtimeRaidMemberRecord with RawNameBytes =
|
||||
// rosterNameBytes(name). See tools\_rev_安徒恩_修复方案.md F15.
|
||||
//
|
||||
// Do not route a live member record through the two fallbacks below. They encode
|
||||
// UTF-16LE, which this reader renders byte by byte -- the same mistake that made
|
||||
// the raid window title show "1" for "1111" and "<y}v1" for "礼白1"
|
||||
// (runtimeRaidTitleBytes in raid_online.go, and the already-fixed town scene
|
||||
// object name in character_scene_object_tail.go writeCurrentSceneObjectName).
|
||||
// They survive only for the legacy compatibility record exercised by body_test.go.
|
||||
func raidWstrBytes(member MemberRecord) ([]byte, error) {
|
||||
if len(member.RawNameBytes) > 0 {
|
||||
return validateRaidNameBytes(member.RawNameBytes)
|
||||
}
|
||||
if len(member.RawNameUTF16LE) > 0 {
|
||||
return validateRaidWstrBytes(member.RawNameUTF16LE)
|
||||
}
|
||||
units := utf16.Encode([]rune(member.Name))
|
||||
if len(units) == 0 {
|
||||
return []byte{0, 0}, nil
|
||||
}
|
||||
out := make([]byte, 0, len(units)*2)
|
||||
for _, unit := range units {
|
||||
out = append(out, byte(unit), byte(unit>>8))
|
||||
}
|
||||
return validateRaidWstrBytes(out)
|
||||
}
|
||||
|
||||
func validateRaidNameBytes(data []byte) ([]byte, error) {
|
||||
if len(data) >= raidMemberNameMaxBytes {
|
||||
return nil, errRaidMemberNameTooLong
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func validateRaidWstrBytes(data []byte) ([]byte, error) {
|
||||
if len(data)%2 != 0 {
|
||||
return nil, errRaidMemberNameInvalid
|
||||
}
|
||||
if len(data) == 0 {
|
||||
data = []byte{0, 0}
|
||||
}
|
||||
if len(data) >= raidMemberNameMaxBytes {
|
||||
return nil, errRaidMemberNameTooLong
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
// validateRaidTitleBytes bounds the raid-name dstr without ever reshaping it.
|
||||
//
|
||||
// The only rule the client imposes is its reader's capacity: sub_3457D20 is
|
||||
// called with 0x100, so a declared length at or above that makes it discard the
|
||||
// payload and fall back to its own default string (669). The bytes themselves
|
||||
// are handed through untouched -- no parity, no NUL padding, no re-encoding:
|
||||
// GB18030 names are routinely an odd number of bytes ("礼白1" is five), so the
|
||||
// old even-length requirement here rejected perfectly legal names, and the
|
||||
// UTF-16 conversion that produced that requirement is exactly what garbled them.
|
||||
//
|
||||
// An empty title is left as an empty dstr, which is what the 86 reference sends
|
||||
// for a raid created without a name; the client then substitutes its own default.
|
||||
func validateRaidTitleBytes(data []byte) ([]byte, error) {
|
||||
if len(data) >= raidTitleMaxBytes {
|
||||
return nil, errRaidMemberNameTooLong
|
||||
}
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
func validateRaidByteCount(count int) error {
|
||||
if count > maxRaidByteCount {
|
||||
return errRaidByteCountOverflow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
// 本文件验证攻坚队包体解析、0x24F 成员刷新和 889 检查结果构造。
|
||||
package raid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
func TestDecodeManagerWorkRequest(t *testing.T) {
|
||||
body := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0)
|
||||
binary.LittleEndian.PutUint32(body[4:8], 1001)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 3)
|
||||
|
||||
got, err := DecodeManagerWorkRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeManagerWorkRequest error = %v", err)
|
||||
}
|
||||
if got.ActionOrMode != 0 || got.MemberCharKey != 1001 || got.TargetGroup != 3 {
|
||||
t.Fatalf("manager work = %+v, want action=0 member=1001 group=3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateAndLeaveRaidResultBodies(t *testing.T) {
|
||||
if got, want := BuildCreateRaidResultBody(0x12345678), []byte{1, 0x78, 0x56, 0x34, 0x12}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("create raid result body = % X, want % X", got, want)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raidEnded bool
|
||||
want []byte
|
||||
}{
|
||||
{name: "member left", raidEnded: false, want: []byte{1, 0}},
|
||||
{name: "raid ended", raidEnded: true, want: []byte{1, 1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := BuildLeaveRaidResultBody(tc.raidEnded); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("leave raid result body = % X, want % X", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentRaidNotificationIDs(t *testing.T) {
|
||||
if RaidObjectMsgID != 0x024f || RaidMemberRefreshMsgID != 0x024f {
|
||||
t.Fatalf("raid object/roster msg ids = %#04x/%#04x, want 0x024f", RaidObjectMsgID, RaidMemberRefreshMsgID)
|
||||
}
|
||||
if RaidTimerMsgID != 0x0254 || RaidRemainTimeMsgID != 0x0255 {
|
||||
t.Fatalf("raid timer msg ids = %#04x/%#04x, want 0x0254/0x0255", RaidTimerMsgID, RaidRemainTimeMsgID)
|
||||
}
|
||||
if RaidWaitingChangeMsgID != 0x0251 || RaidPrepareStartMsgID != 0x0252 {
|
||||
t.Fatalf("raid waiting/prepare msg ids = %#04x/%#04x, want 0x0251/0x0252", RaidWaitingChangeMsgID, RaidPrepareStartMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidCreationBodies(t *testing.T) {
|
||||
member := MemberRecord{
|
||||
CharKey: 0x1234,
|
||||
Field4: 1,
|
||||
RawNameUTF16LE: []byte{'L', 0},
|
||||
Field40: 2,
|
||||
Field44: 3,
|
||||
GroupIndex: 1,
|
||||
SlotOrder: 0,
|
||||
Field48: 9,
|
||||
Field52: 4,
|
||||
Field53: 5,
|
||||
Field56: 6,
|
||||
Field60: 10,
|
||||
Field64: 11,
|
||||
Field66BoolSource: 1,
|
||||
}
|
||||
got, err := BuildRaidObjectMode0Body(RaidObjectMode0{
|
||||
RaidKey: 0x11223344,
|
||||
TitleBytes: []byte{'R', '1'},
|
||||
ObjectField24: 3,
|
||||
ObjectField28: 0,
|
||||
ObjectField30: 0xff,
|
||||
ObjectField34: 5,
|
||||
ObjectField94: 1,
|
||||
Leader: member,
|
||||
Members: []MemberRecord{member},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidObjectMode0Body error = %v", err)
|
||||
}
|
||||
wantMember := []byte{
|
||||
0x34, 0x12, 1,
|
||||
2, 0, 0, 0, 'L', 0,
|
||||
2, 3, 1, 0,
|
||||
9, 0, 0, 0,
|
||||
4, 5, 6,
|
||||
10, 0, 0, 0,
|
||||
11, 0,
|
||||
1, 0, 0, 0,
|
||||
}
|
||||
want := []byte{
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0, 0, 0, 0,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
2, 0, 0, 0, 'R', '1',
|
||||
3, 0, 0xff,
|
||||
5, 0, 0, 0,
|
||||
1,
|
||||
}
|
||||
want = append(want, wantMember...)
|
||||
want = append(want, 1)
|
||||
want = append(want, wantMember...)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("mode0 raid object body = % X, want % X", got, want)
|
||||
}
|
||||
if got := BuildEmptyRaidBuffBody(); !reflect.DeepEqual(got, []byte{0}) {
|
||||
t.Fatalf("empty buff body = % X, want 00", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidStateNotificationBodies(t *testing.T) {
|
||||
symbols, err := BuildRaidSymbolBody([]RaidSymbolRow{
|
||||
{Key: 0x11223344, Value: 0x55667788},
|
||||
{Key: 0x01020304, Value: 0xaabbccdd},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidSymbolBody error = %v", err)
|
||||
}
|
||||
wantSymbols := []byte{
|
||||
2,
|
||||
0x44, 0x33, 0x22, 0x11, 0x88, 0x77, 0x66, 0x55,
|
||||
0x04, 0x03, 0x02, 0x01, 0xdd, 0xcc, 0xbb, 0xaa,
|
||||
}
|
||||
if !reflect.DeepEqual(symbols, wantSymbols) {
|
||||
t.Fatalf("raid symbol body = % X, want % X", symbols, wantSymbols)
|
||||
}
|
||||
|
||||
participation, err := BuildRaidParticipationBody([]RaidParticipationRow{
|
||||
{DungeonID: 0x11223344, Op: 5, MemberKeys: []uint32{0x1234, 0xabcd}},
|
||||
{DungeonID: 0x01020304},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidParticipationBody error = %v", err)
|
||||
}
|
||||
wantParticipation := []byte{
|
||||
2,
|
||||
0x44, 0x33, 0x22, 0x11, 5, 2, 0x34, 0x12, 0xcd, 0xab,
|
||||
0x04, 0x03, 0x02, 0x01, 0, 0,
|
||||
}
|
||||
if !reflect.DeepEqual(participation, wantParticipation) {
|
||||
t.Fatalf("raid participation body = % X, want % X", participation, wantParticipation)
|
||||
}
|
||||
|
||||
dungeonState, err := BuildRaidDungeonStateBody(RaidDungeonStateSnapshot{
|
||||
Unknown0: 7,
|
||||
Rows: []RaidDungeonStateRow{
|
||||
{DungeonID: 0x11223344, State: 1},
|
||||
{DungeonID: 0x01020304, State: 9},
|
||||
},
|
||||
TrailingValue: 0xaabbccdd,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidDungeonStateBody error = %v", err)
|
||||
}
|
||||
wantDungeonState := []byte{
|
||||
7, 2,
|
||||
0x44, 0x33, 0x22, 0x11, 1,
|
||||
0x04, 0x03, 0x02, 0x01, 9,
|
||||
0xdd, 0xcc, 0xbb, 0xaa,
|
||||
}
|
||||
if !reflect.DeepEqual(dungeonState, wantDungeonState) {
|
||||
t.Fatalf("raid dungeon state body = % X, want % X", dungeonState, wantDungeonState)
|
||||
}
|
||||
|
||||
if got, want := BuildRaidDungeonChangeBody(RaidDungeonChange{
|
||||
DungeonID: 0x11223344,
|
||||
State: 3,
|
||||
Argument: 0x80,
|
||||
}), []byte{0x44, 0x33, 0x22, 0x11, 3, 0x80}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid dungeon change body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildRaidStateBody(RaidState{State: 9, Argument: -2}), []byte{9, 0xfe}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid state body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidTimerMovieAndResultBodies(t *testing.T) {
|
||||
if got, want := BuildRaidTimerBody(RaidTimer{
|
||||
TimerType: 2,
|
||||
Value0: 0x11223344,
|
||||
Value1: 0x55667788,
|
||||
Value2: 0xaabbccdd,
|
||||
}), []byte{
|
||||
2,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
0xdd, 0xcc, 0xbb, 0xaa,
|
||||
}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid timer body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildRaidRemainTimeBody(RaidRemainTime{
|
||||
TimerType: 3,
|
||||
Seconds: 0x01020304,
|
||||
}), []byte{3, 0x04, 0x03, 0x02, 0x01}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid remain-time body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildRaidMovieBody(), []byte{}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid movie body = % X, want empty", got)
|
||||
}
|
||||
if got, want := BuildRaidResultBody(RaidResult{
|
||||
ResultType: 2,
|
||||
Field1: 3,
|
||||
Field2: 0x11223344,
|
||||
Field3: 0x5566,
|
||||
Field4: 7,
|
||||
Field5: 8,
|
||||
}), []byte{2, 3, 0x44, 0x33, 0x22, 0x11, 0x66, 0x55, 7, 8}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid result body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidRewardAndBuffBodies(t *testing.T) {
|
||||
rewards, err := BuildRaidRewardListBody(RaidRewardList{
|
||||
ResultType: 1,
|
||||
Groups: []RaidRewardGroup{
|
||||
{
|
||||
MemberKey: 0x1234,
|
||||
Field0: 5,
|
||||
Entries: []RaidRewardEntry{
|
||||
{Field1: 7, ItemOrValue: 0x11223344, CountOrValue: 0x5566},
|
||||
{Field1: 8, ItemOrValue: 0xaabbccdd, CountOrValue: 0xeeff},
|
||||
},
|
||||
},
|
||||
{MemberKey: 0xabcd},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidRewardListBody error = %v", err)
|
||||
}
|
||||
wantRewards := []byte{
|
||||
1, 2,
|
||||
0x34, 0x12, 5, 2,
|
||||
7, 0x44, 0x33, 0x22, 0x11, 0x66, 0x55,
|
||||
8, 0xdd, 0xcc, 0xbb, 0xaa, 0xff, 0xee,
|
||||
0xcd, 0xab, 0, 0,
|
||||
}
|
||||
if !reflect.DeepEqual(rewards, wantRewards) {
|
||||
t.Fatalf("raid reward-list body = % X, want % X", rewards, wantRewards)
|
||||
}
|
||||
|
||||
buffs, err := BuildRaidBuffBody([]RaidBuffGroup{
|
||||
{
|
||||
Field0: 1,
|
||||
Entries: []RaidBuffEntry{
|
||||
{
|
||||
PartyIndex: 0x0203,
|
||||
MemberKey: 0x1234,
|
||||
Value0: 0x11223344,
|
||||
Value1: 0x55667788,
|
||||
},
|
||||
},
|
||||
},
|
||||
{Field0: 9},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidBuffBody error = %v", err)
|
||||
}
|
||||
wantBuffs := []byte{
|
||||
2,
|
||||
1, 1,
|
||||
0x03, 0x02, 0x34, 0x12,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
9, 0,
|
||||
}
|
||||
if !reflect.DeepEqual(buffs, wantBuffs) {
|
||||
t.Fatalf("raid buff body = % X, want % X", buffs, wantBuffs)
|
||||
}
|
||||
emptyBuffs, err := BuildRaidBuffBody(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidBuffBody empty error = %v", err)
|
||||
}
|
||||
if got, want := emptyBuffs, BuildEmptyRaidBuffBody(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("empty raid buff body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidMonsterStateBody(t *testing.T) {
|
||||
got, err := BuildRaidMonsterStateBody([]RaidMonsterStateGroup{
|
||||
{
|
||||
PartyIndex: 0x0203,
|
||||
Members: []uint16{0x1234, 0xabcd},
|
||||
Value0: 0x11223344,
|
||||
Value1: 0x55667788,
|
||||
Values: []uint32{0x01020304, 0xaabbccdd},
|
||||
},
|
||||
{
|
||||
PartyIndex: 0x0405,
|
||||
Value1: 0x10203040,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidMonsterStateBody error = %v", err)
|
||||
}
|
||||
want := []byte{
|
||||
2,
|
||||
0x03, 0x02, 2, 0x34, 0x12, 0xcd, 0xab,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
2, 0x04, 0x03, 0x02, 0x01, 0xdd, 0xcc, 0xbb, 0xaa,
|
||||
0x05, 0x04, 0,
|
||||
0, 0, 0, 0,
|
||||
0x40, 0x30, 0x20, 0x10,
|
||||
0,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid monster-state body = % X, want % X", got, want)
|
||||
}
|
||||
empty, err := BuildRaidMonsterStateBody(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidMonsterStateBody empty error = %v", err)
|
||||
}
|
||||
if got, want := empty, BuildEmptyRaidMonsterStateBody(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("empty raid monster-state body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidByteSizedCountsRejectTheTwoHundredFiftySixthRow(t *testing.T) {
|
||||
tooManyMembers := make([]uint16, maxRaidByteCount+1)
|
||||
tooManyValues := make([]uint32, maxRaidByteCount+1)
|
||||
tooManySymbols := make([]RaidSymbolRow, maxRaidByteCount+1)
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "outer count",
|
||||
call: func() error {
|
||||
_, err := BuildRaidSymbolBody(tooManySymbols)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "participation members",
|
||||
call: func() error {
|
||||
_, err := BuildRaidParticipationBody([]RaidParticipationRow{{Members: tooManyMembers}})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reward entries",
|
||||
call: func() error {
|
||||
_, err := BuildRaidRewardListBody(RaidRewardList{Groups: []RaidRewardGroup{{
|
||||
Entries: make([]RaidRewardEntry, maxRaidByteCount+1),
|
||||
}}})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "buff entries",
|
||||
call: func() error {
|
||||
_, err := BuildRaidBuffBody([]RaidBuffGroup{{
|
||||
Entries: make([]RaidBuffEntry, maxRaidByteCount+1),
|
||||
}})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "monster members",
|
||||
call: func() error {
|
||||
_, err := BuildRaidMonsterStateBody([]RaidMonsterStateGroup{{Members: tooManyMembers}})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "monster values",
|
||||
call: func() error {
|
||||
_, err := BuildRaidMonsterStateBody([]RaidMonsterStateGroup{{Values: tooManyValues}})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.call(); err != errRaidByteCountOverflow {
|
||||
t.Fatalf("error = %v, want %v", err, errRaidByteCountOverflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentRaidEntryCostAndWaitingBodies(t *testing.T) {
|
||||
if got, want := BuildRaidCommandSuccessBody(), []byte{1}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raid success body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildEntryCostBody([]EntryCostRow{
|
||||
{MemberKey: 0x1234, Field: 1, Value: 7},
|
||||
{MemberKey: 0xabcd, Field: 0, Value: 0},
|
||||
}), []byte{
|
||||
2, 0, 0, 0,
|
||||
0x34, 0x12, 1, 7, 0,
|
||||
0xcd, 0xab, 0, 0, 0,
|
||||
}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("entry cost body = % X, want % X", got, want)
|
||||
}
|
||||
rows := []WaitingRow{{MemberKey: 0x1234, State: 2}, {MemberKey: 0xabcd, State: 0}}
|
||||
if got, want := BuildWaitingListBody(rows), []byte{2, 0, 0x34, 0x12, 2, 0xcd, 0xab, 0}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("waiting list body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildWaitingChangeBody(0, rows[0]), []byte{0, 0x34, 0x12, 2}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("waiting add body = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildWaitingChangeBody(1, rows[1]), []byte{1, 0xcd, 0xab}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("waiting remove body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCreateAndModifyRaidInfoRequest(t *testing.T) {
|
||||
body := buildRaidInfoBodyForTest(2, []byte("anton"), 7)
|
||||
|
||||
create, err := DecodeCreateRaidRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCreateRaidRequest error = %v", err)
|
||||
}
|
||||
modify, err := DecodeModifyRaidInfoRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeModifyRaidInfoRequest error = %v", err)
|
||||
}
|
||||
for name, got := range map[string]InfoRequest{"create": create, "modify": modify} {
|
||||
if got.RouteOrRaidType != 2 || !bytesEqual(got.NameBytes, []byte("anton")) || got.TailFlag != 7 {
|
||||
t.Fatalf("%s request = %+v, want route=2 name=anton tail=7", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRequestRaidInfoRequest(t *testing.T) {
|
||||
body := []byte{2, 0x78, 0x56, 0x34, 0x12}
|
||||
got, err := DecodeRequestRaidInfoRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeRequestRaidInfoRequest error = %v", err)
|
||||
}
|
||||
if !bytesEqual(got.Raw[:], body) {
|
||||
t.Fatalf("request raid info = %+v", got)
|
||||
}
|
||||
for _, size := range []int{0, 4, 6} {
|
||||
if _, err := DecodeRequestRaidInfoRequest(make([]byte, size)); err == nil {
|
||||
t.Fatalf("DecodeRequestRaidInfoRequest accepted %d-byte body", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidInfoResponseBodyIsEmpty(t *testing.T) {
|
||||
if got := BuildRaidInfoResponseBody(); len(got) != 0 {
|
||||
t.Fatalf("raid info response body = % X, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidDirectoryReadyBodyHasReservedByte(t *testing.T) {
|
||||
if got := BuildRaidDirectoryReadyBody(); len(got) != 1 || got[0] != 0 {
|
||||
t.Fatalf("raid directory ready body = % X, want 00", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCreateRaidInfoRequestRejectsTruncatedName(t *testing.T) {
|
||||
body := []byte{1, 5, 0, 0, 0, 'a', 'b'}
|
||||
if _, err := DecodeCreateRaidRequest(body); err == nil {
|
||||
t.Fatalf("DecodeCreateRaidRequest accepted truncated dstr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidRequestDecodersRejectTrailingBytes(t *testing.T) {
|
||||
info := append(buildRaidInfoBodyForTest(2, []byte("anton"), 7), 0)
|
||||
if _, err := DecodeCreateRaidRequest(info); err == nil {
|
||||
t.Fatalf("DecodeCreateRaidRequest accepted trailing byte")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
call func([]byte) error
|
||||
}{
|
||||
{name: "leave", body: []byte{1, 0, 0}, call: func(body []byte) error { _, err := DecodeLeaveRaidRequest(body); return err }},
|
||||
{name: "rejoin", body: []byte{1, 0, 0, 0, 0}, call: func(body []byte) error { _, err := DecodeRejoinRaidRequest(body); return err }},
|
||||
{name: "waiting", body: []byte{0, 2, 0}, call: func(body []byte) error { _, err := DecodeSetWaitingRequest(body); return err }},
|
||||
{name: "entry cost", body: []byte{1, 0}, call: func(body []byte) error { _, err := DecodeEntryCostInfoRequest(body); return err }},
|
||||
{name: "set symbol", body: make([]byte, 10), call: func(body []byte) error { _, err := DecodeSetSymbolRequest(body); return err }},
|
||||
{name: "manager work", body: make([]byte, 13), call: func(body []byte) error { _, err := DecodeManagerWorkRequest(body); return err }},
|
||||
{name: "other channel join", body: make([]byte, 10), call: func(body []byte) error { _, err := DecodeOtherChannelRequestJoinRequest(body); return err }},
|
||||
{name: "member state", body: []byte{1, 0}, call: func(body []byte) error { _, err := DecodeMemberChangeStateRequest(body); return err }},
|
||||
{name: "move channel fail", body: []byte{1, 2, 0, 0}, call: func(body []byte) error { _, err := DecodeUserMoveChannelFailRequest(body); return err }},
|
||||
{name: "simple other channel list", body: []byte{1, 0}, call: func(body []byte) error { _, err := DecodeOtherChannelListRequest(body); return err }},
|
||||
{name: "check user", body: []byte{1, 2, 0, 0}, call: func(body []byte) error { _, err := DecodeCheckRaidUserRequest(body); return err }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.call(tc.body); err == nil {
|
||||
t.Fatalf("decoder accepted trailing byte: % X", tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fullList := make([]byte, 14)
|
||||
fullList[0] = 0
|
||||
if _, err := DecodeOtherChannelListRequest(fullList); err == nil {
|
||||
t.Fatalf("DecodeOtherChannelListRequest accepted trailing byte after empty dstr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSetSymbolRejectsOutOfRangeSymbol(t *testing.T) {
|
||||
body := make([]byte, 9)
|
||||
body[8] = 3
|
||||
if _, err := DecodeSetSymbolRequest(body); err == nil {
|
||||
t.Fatalf("DecodeSetSymbolRequest accepted symbol 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLeaveStartAndRejoinRaidRequests(t *testing.T) {
|
||||
leave, err := DecodeLeaveRaidRequest([]byte{0x34, 0x12})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeLeaveRaidRequest error = %v", err)
|
||||
}
|
||||
if leave.RaidOrMemberKey != 0x1234 {
|
||||
t.Fatalf("leave key = 0x%04X, want 0x1234", leave.RaidOrMemberKey)
|
||||
}
|
||||
if err := DecodeStartRaidRequest(nil); err != nil {
|
||||
t.Fatalf("DecodeStartRaidRequest empty error = %v", err)
|
||||
}
|
||||
if err := DecodeStartRaidRequest([]byte{1}); err == nil {
|
||||
t.Fatalf("DecodeStartRaidRequest accepted non-empty body")
|
||||
}
|
||||
rejoin, err := DecodeRejoinRaidRequest([]byte{0x78, 0x56, 0x34, 0x12})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeRejoinRaidRequest error = %v", err)
|
||||
}
|
||||
if rejoin.RaidKey != 0x12345678 {
|
||||
t.Fatalf("rejoin raid key = 0x%08X, want 0x12345678", rejoin.RaidKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRaidNeighborRequests(t *testing.T) {
|
||||
waiting, err := DecodeSetWaitingRequest([]byte{0, 2})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeSetWaitingRequest error = %v", err)
|
||||
}
|
||||
if waiting.Flag != 0 || waiting.RouteRaidType != 2 {
|
||||
t.Fatalf("waiting = %+v, want flag=0 route=2", waiting)
|
||||
}
|
||||
|
||||
entryCost, err := DecodeEntryCostInfoRequest([]byte{1})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeEntryCostInfoRequest error = %v", err)
|
||||
}
|
||||
if entryCost.Enabled != 1 {
|
||||
t.Fatalf("entry cost enabled = %d, want 1", entryCost.Enabled)
|
||||
}
|
||||
|
||||
setSymbolBody := make([]byte, 9)
|
||||
binary.LittleEndian.PutUint32(setSymbolBody[0:4], 0x11223344)
|
||||
binary.LittleEndian.PutUint32(setSymbolBody[4:8], 0x55667788)
|
||||
setSymbolBody[8] = 2
|
||||
setSymbol, err := DecodeSetSymbolRequest(setSymbolBody)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeSetSymbolRequest error = %v", err)
|
||||
}
|
||||
if setSymbol.SourceValue != 0x11223344 || setSymbol.TargetValue != 0x55667788 || setSymbol.Symbol != 2 {
|
||||
t.Fatalf("set symbol = %+v, want source=0x11223344 target=0x55667788 symbol=2", setSymbol)
|
||||
}
|
||||
|
||||
joinBody := []byte{1, 0x34, 0x12, 0x78, 0x56, 0x34, 0x12, 0x02, 0x00}
|
||||
join, err := DecodeOtherChannelRequestJoinRequest(joinBody)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeOtherChannelRequestJoinRequest error = %v", err)
|
||||
}
|
||||
if join.Mode != 1 || join.TargetKey != 0x1234 || join.ClientValue != 0x12345678 || join.RouteRaidType != 2 {
|
||||
t.Fatalf("join = %+v, want mode=1 target=0x1234 client=0x12345678 route=2", join)
|
||||
}
|
||||
|
||||
state, err := DecodeMemberChangeStateRequest([]byte{3})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMemberChangeStateRequest error = %v", err)
|
||||
}
|
||||
if state.State != 3 {
|
||||
t.Fatalf("state = %d, want 3", state.State)
|
||||
}
|
||||
|
||||
moveFail, err := DecodeUserMoveChannelFailRequest([]byte{4, 0x78, 0x56})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeUserMoveChannelFailRequest error = %v", err)
|
||||
}
|
||||
if moveFail.Mode != 4 || moveFail.TargetKey != 0x5678 {
|
||||
t.Fatalf("move fail = %+v, want mode=4 target=0x5678", moveFail)
|
||||
}
|
||||
|
||||
check, err := DecodeCheckRaidUserRequest([]byte{5, 0xbc, 0x9a})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeCheckRaidUserRequest error = %v", err)
|
||||
}
|
||||
if check.Mode != 5 || check.TargetKey != 0x9abc {
|
||||
t.Fatalf("check = %+v, want mode=5 target=0x9abc", check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeOtherChannelListRequest(t *testing.T) {
|
||||
simple, err := DecodeOtherChannelListRequest([]byte{1})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeOtherChannelListRequest simple error = %v", err)
|
||||
}
|
||||
if simple.Mode != 1 || simple.HasContext || simple.HasNameDstr {
|
||||
t.Fatalf("simple list = %+v, want mode=1 without context", simple)
|
||||
}
|
||||
|
||||
body := make([]byte, 13+4)
|
||||
body[0] = 0
|
||||
copy(body[1:9], []byte{1, 2, 3, 4, 5, 6, 7, 8})
|
||||
binary.LittleEndian.PutUint32(body[9:13], 4)
|
||||
copy(body[13:], []byte("raid"))
|
||||
full, err := DecodeOtherChannelListRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeOtherChannelListRequest full error = %v", err)
|
||||
}
|
||||
if full.Mode != 0 || !full.HasContext || !full.HasNameDstr || !bytesEqual(full.NameBytes, []byte("raid")) {
|
||||
t.Fatalf("full list = %+v, want mode=0 context name=raid", full)
|
||||
}
|
||||
if full.Context != [8]byte{1, 2, 3, 4, 5, 6, 7, 8} {
|
||||
t.Fatalf("context = % X", full.Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCheckUserResultBody(t *testing.T) {
|
||||
got := BuildCheckUserResultBody(CheckUserResult{
|
||||
Header: CheckUserHeader{
|
||||
Mode: CheckUserModeRefreshWithHeaderState,
|
||||
Field4: 0x11223344,
|
||||
Field8: 0x55667788,
|
||||
Field12: 0x99,
|
||||
Raw13To20: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||
Field21To24: 0xaabbccdd,
|
||||
},
|
||||
FirstRows: [][12]byte{
|
||||
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b},
|
||||
},
|
||||
SecondRows: [][12]byte{
|
||||
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b},
|
||||
},
|
||||
StateRows: [][5]byte{
|
||||
{0x30, 0x31, 0x32, 0x33, 0x34},
|
||||
},
|
||||
})
|
||||
want := []byte{
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
0x99,
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08,
|
||||
0xdd, 0xcc, 0xbb, 0xaa,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x10, 0x11, 0x12, 0x13,
|
||||
0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1a, 0x1b,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x20, 0x21, 0x22, 0x23,
|
||||
0x24, 0x25, 0x26, 0x27,
|
||||
0x28, 0x29, 0x2a, 0x2b,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x30, 0x31, 0x32, 0x33, 0x34,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("check user body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestMembersResultBody(t *testing.T) {
|
||||
got := BuildRequestMembersResultBody(RequestMembersResultEnterPublicRaidState, 0x55)
|
||||
want := []byte{0x02, 0x00, 0x00, 0x00, 0x55}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("request members result body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidCheckUserAndRequestMemberConstants(t *testing.T) {
|
||||
if CheckUserModeRefresh != 0 || CheckUserModeRefreshWithHeaderState != 1 {
|
||||
t.Fatalf("check user modes = %d/%d, want 0/1", CheckUserModeRefresh, CheckUserModeRefreshWithHeaderState)
|
||||
}
|
||||
if RequestMembersResultEnterPublicRaidState != 2 || RequestMembersResultSkipLocalError != 3 {
|
||||
t.Fatalf("request member results = %d/%d, want 2/3", RequestMembersResultEnterPublicRaidState, RequestMembersResultSkipLocalError)
|
||||
}
|
||||
if requestMembersUIStatePublicRaid != 8 || requestMembersUIStateLocalFailure != 10 {
|
||||
t.Fatalf("request member UI states = %d/%d, want 8/10", requestMembersUIStatePublicRaid, requestMembersUIStateLocalFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOtherChannelBodies(t *testing.T) {
|
||||
publicObject := RaidObjectMode0{
|
||||
RaidKey: 0x00170001,
|
||||
TitleBytes: []byte{'R', '1'},
|
||||
ObjectField24: 5,
|
||||
ObjectField30: 0xff,
|
||||
Leader: MemberRecord{CharKey: 1, RawNameBytes: []byte("leader")},
|
||||
Members: []MemberRecord{{CharKey: 1, RawNameBytes: []byte("leader")}},
|
||||
}
|
||||
publicBody, err := BuildPublicRaidListBody(PublicRaidListPage{
|
||||
TotalCount: 1,
|
||||
Objects: []RaidObjectMode0{publicObject},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPublicRaidListBody error = %v", err)
|
||||
}
|
||||
coreObject := publicObject
|
||||
coreObject.Members = nil
|
||||
mode0Body, err := BuildRaidObjectMode0Body(coreObject)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidObjectMode0Body core error = %v", err)
|
||||
}
|
||||
wantPublic := []byte{
|
||||
1, 0, 0, 0, // total count
|
||||
0, 0, 0, 0, // returned offset
|
||||
1, 0, 0, 0, // page row count
|
||||
}
|
||||
// op591 adds raid-key/mode before the shared object and its own member count
|
||||
// plus member records after it. op753 embeds only the shared object plus its
|
||||
// member count.
|
||||
wantPublic = append(wantPublic, mode0Body[8:len(mode0Body)-1]...)
|
||||
wantPublic = append(wantPublic, 1)
|
||||
if !reflect.DeepEqual(publicBody, wantPublic) {
|
||||
t.Fatalf("public raid list body = % X, want % X", publicBody, wantPublic)
|
||||
}
|
||||
|
||||
emptyPage := BuildOtherChannelListPageBody(OtherChannelListPage{})
|
||||
if len(emptyPage) != 28 {
|
||||
t.Fatalf("empty other channel list page length = %d, want 28", len(emptyPage))
|
||||
}
|
||||
if !reflect.DeepEqual(emptyPage[20:], []byte{0, 0, 0, 0, 0, 0, 0, 0}) {
|
||||
t.Fatalf("empty other channel list counts = % X, want eight zero bytes", emptyPage[20:])
|
||||
}
|
||||
|
||||
userinfo := BuildOtherChannelUserinfoBody([]uint32{0x11223344, 0x55667788})
|
||||
wantUserinfo := []byte{
|
||||
0x02, 0x00, 0x00, 0x00,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
}
|
||||
if !reflect.DeepEqual(userinfo, wantUserinfo) {
|
||||
t.Fatalf("other channel userinfo body = % X, want % X", userinfo, wantUserinfo)
|
||||
}
|
||||
|
||||
join := BuildOtherChannelRequestJoinResultBody(0x01020304, 0xaabbccdd)
|
||||
wantJoin := []byte{0x04, 0x03, 0x02, 0x01, 0xdd, 0xcc, 0xbb, 0xaa}
|
||||
if !reflect.DeepEqual(join, wantJoin) {
|
||||
t.Fatalf("other channel join result body = % X, want % X", join, wantJoin)
|
||||
}
|
||||
|
||||
response := BuildOtherChannelResponseJoinBody(7, 1)
|
||||
if !reflect.DeepEqual(response, []byte{7, 1}) {
|
||||
t.Fatalf("other channel response join body = % X, want 07 01", response)
|
||||
}
|
||||
|
||||
var header [20]byte
|
||||
for i := range header {
|
||||
header[i] = byte(i + 1)
|
||||
}
|
||||
page := BuildOtherChannelListPageBody(OtherChannelListPage{
|
||||
Header: header,
|
||||
Rows12: [][12]byte{
|
||||
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b},
|
||||
},
|
||||
Rows13: [][13]byte{
|
||||
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c},
|
||||
},
|
||||
})
|
||||
wantPage := []byte{
|
||||
0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0a, 0x0b, 0x0c,
|
||||
0x0d, 0x0e, 0x0f, 0x10,
|
||||
0x11, 0x12, 0x13, 0x14,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x10, 0x11, 0x12, 0x13,
|
||||
0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1a, 0x1b,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0x20, 0x21, 0x22, 0x23,
|
||||
0x24, 0x25, 0x26, 0x27,
|
||||
0x28, 0x29, 0x2a, 0x2b,
|
||||
0x2c,
|
||||
}
|
||||
if !reflect.DeepEqual(page, wantPage) {
|
||||
t.Fatalf("other channel list page body = % X, want % X", page, wantPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberRefreshMode3Body(t *testing.T) {
|
||||
got, err := BuildMemberRefreshMode3Body(MemberRefresh{
|
||||
RaidKey: 0x01020304,
|
||||
Members: []MemberRecord{
|
||||
{
|
||||
CharKey: 1001,
|
||||
Field4: 1,
|
||||
Name: "A",
|
||||
Field40: 2,
|
||||
Field44: 3,
|
||||
GroupIndex: 4,
|
||||
SlotOrder: 5,
|
||||
Field48: 0x11223344,
|
||||
Field52: 6,
|
||||
Field53: 7,
|
||||
Field56: 8,
|
||||
Field60: 0x55667788,
|
||||
Field64: 0x99aa,
|
||||
Field66BoolSource: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildMemberRefreshMode3Body error = %v", err)
|
||||
}
|
||||
want := []byte{
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
0x03, 0x00, 0x00, 0x00,
|
||||
0x01,
|
||||
0xe9, 0x03,
|
||||
0x01,
|
||||
0x02, 0x00, 0x00, 0x00,
|
||||
0x41, 0x00,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x44, 0x33, 0x22, 0x11,
|
||||
0x06,
|
||||
0x07,
|
||||
0x08,
|
||||
0x88, 0x77, 0x66, 0x55,
|
||||
0xaa, 0x99,
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("refresh body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberRefreshMode3BodyWritesNonZeroEmptyName(t *testing.T) {
|
||||
got, err := BuildMemberRefreshMode3Body(MemberRefresh{
|
||||
Members: []MemberRecord{{CharKey: 1001}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildMemberRefreshMode3Body error = %v", err)
|
||||
}
|
||||
nameLenOffset := 8 + 1 + 2 + 1
|
||||
if length := binary.LittleEndian.Uint32(got[nameLenOffset : nameLenOffset+4]); length != 2 {
|
||||
t.Fatalf("empty name length = %d, want 2", length)
|
||||
}
|
||||
if !reflect.DeepEqual(got[nameLenOffset+4:nameLenOffset+6], []byte{0, 0}) {
|
||||
t.Fatalf("empty name bytes = % X, want 00 00", got[nameLenOffset+4:nameLenOffset+6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberRefreshMode3BodyUsesRawNameBytes(t *testing.T) {
|
||||
got, err := BuildMemberRefreshMode3Body(MemberRefresh{
|
||||
RaidKey: 0x01020304,
|
||||
Members: []MemberRecord{{
|
||||
CharKey: 1001,
|
||||
Name: "七千万",
|
||||
RawNameBytes: []byte{0xc6, 0xdf, 0xc7, 0xa7, 0xcd, 0xf2},
|
||||
RawNameUTF16LE: []byte{0x03, 0x4e, 0x43, 0x53, 0x07, 0x4e},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildMemberRefreshMode3Body error = %v", err)
|
||||
}
|
||||
nameLenOffset := 8 + 1 + 2 + 1
|
||||
if length := binary.LittleEndian.Uint32(got[nameLenOffset : nameLenOffset+4]); length != 6 {
|
||||
t.Fatalf("raw name length = %d, want 6", length)
|
||||
}
|
||||
if gotName := got[nameLenOffset+4 : nameLenOffset+10]; !reflect.DeepEqual(gotName, []byte{0xc6, 0xdf, 0xc7, 0xa7, 0xcd, 0xf2}) {
|
||||
t.Fatalf("raw name bytes = % X, want C6 DF C7 A7 CD F2", gotName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberRefreshMode3BodyRejectsTwentyFirstMember(t *testing.T) {
|
||||
members := make([]MemberRecord, MaxAttackPartyMembers+1)
|
||||
if _, err := BuildMemberRefreshMode3Body(MemberRefresh{Members: members}); err == nil {
|
||||
t.Fatalf("BuildMemberRefreshMode3Body accepted %d members", len(members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberRefreshMode3BodyAllowsTwentyMembers(t *testing.T) {
|
||||
members := make([]MemberRecord, MaxAttackPartyMembers)
|
||||
for i := range members {
|
||||
members[i].CharKey = uint16(1000 + i)
|
||||
}
|
||||
got, err := BuildMemberRefreshMode3Body(MemberRefresh{RaidKey: 0x20010001, Members: members})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildMemberRefreshMode3Body error = %v", err)
|
||||
}
|
||||
if got[8] != MaxAttackPartyMembers {
|
||||
t.Fatalf("member count = %d, want %d", got[8], MaxAttackPartyMembers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidHandlerParsesManagerWorkWithoutAck(t *testing.T) {
|
||||
body := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(body[4:8], 1001)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 4)
|
||||
|
||||
got, err := NewHandler().Handle(contextForTest(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRaidManagerWork),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle raid manager work error = %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
||||
t.Fatalf("raid manager handled=%v responseAllowed=%v responses=%d", got.Handled, got.ResponseAllowed, len(got.UpperResponses))
|
||||
}
|
||||
if !strings.Contains(got.Reason, "0x24F mode=3") {
|
||||
t.Fatalf("reason = %q, want 0x24F mode=3 hint", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidHandlerParsesSetSymbolWithoutAck(t *testing.T) {
|
||||
body := make([]byte, 9)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0x11223344)
|
||||
binary.LittleEndian.PutUint32(body[4:8], 0x55667788)
|
||||
body[8] = 1
|
||||
|
||||
got, err := NewHandler().Handle(contextForTest(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRaidSetSymbol),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle raid set symbol error = %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
||||
t.Fatalf("raid set symbol handled=%v responseAllowed=%v responses=%d", got.Handled, got.ResponseAllowed, len(got.UpperResponses))
|
||||
}
|
||||
if !strings.Contains(got.Reason, "RaidSetSymbol") {
|
||||
t.Fatalf("reason = %q, want RaidSetSymbol hint", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func contextForTest() context.Context { return context.Background() }
|
||||
|
||||
func buildRaidInfoBodyForTest(route byte, name []byte, tail byte) []byte {
|
||||
body := make([]byte, 1+4+len(name)+1)
|
||||
body[0] = route
|
||||
binary.LittleEndian.PutUint32(body[1:5], uint32(len(name)))
|
||||
copy(body[5:], name)
|
||||
body[len(body)-1] = tail
|
||||
return body
|
||||
}
|
||||
|
||||
func bytesEqual(left []byte, right []byte) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 本文件按 NoPack S2C 889/sub_1D42AA0 的读取顺序构造攻坚用户检查结果体。
|
||||
package raid
|
||||
|
||||
// RaidCheckUserResultMsgID 是当前 EXE 注册的攻坚用户检查 S2C command id。
|
||||
const RaidCheckUserResultMsgID uint16 = 889
|
||||
|
||||
// RaidRequestMembersResultMsgID 是当前 EXE 注册的攻坚成员请求 S2C command id。
|
||||
const RaidRequestMembersResultMsgID uint16 = 888
|
||||
|
||||
const (
|
||||
// CheckUserModeRefresh 对应 sub_1D42AA0 的 mode=0 分支:清空对象、读取三组列表并刷新 UI。
|
||||
CheckUserModeRefresh uint32 = 0
|
||||
// CheckUserModeRefreshWithHeaderState 对应 sub_1D42AA0 的 mode=1 分支:同样读取三组列表,并额外使用 25 字节头里的状态字段。
|
||||
CheckUserModeRefreshWithHeaderState uint32 = 1
|
||||
)
|
||||
|
||||
const (
|
||||
// RequestMembersResultEnterPublicRaidState 对应 sub_1D3B0E0 的 result=2 分支:调用 sub_1CA88E0(obj, 8)。
|
||||
RequestMembersResultEnterPublicRaidState uint32 = 2
|
||||
// RequestMembersResultSkipLocalError 对应 sub_1D3B0E0 的 result=3 分支:跳过本地错误提示和 state=10 切换。
|
||||
RequestMembersResultSkipLocalError uint32 = 3
|
||||
// requestMembersUIStatePublicRaid 是 888 result=2 触发的客户端 UI 状态码,不是服务端回包字段。
|
||||
requestMembersUIStatePublicRaid uint32 = 8
|
||||
// requestMembersUIStateLocalFailure 是 888 其他失败分支触发的客户端 UI 状态码,不是服务端回包字段。
|
||||
requestMembersUIStateLocalFailure uint32 = 10
|
||||
)
|
||||
|
||||
// CheckUserHeader 是 sub_1D42AA0 开头 upper_pkt_read_raw(0x19) 读取的 25 字节头。
|
||||
// offset 13..20 暂无可靠语义名,先保留原始 8 字节,避免乱命名污染服务端 owner。
|
||||
type CheckUserHeader struct {
|
||||
Mode uint32
|
||||
Field4 uint32
|
||||
Field8 uint32
|
||||
Field12 byte
|
||||
Raw13To20 [8]byte
|
||||
Field21To24 uint32
|
||||
}
|
||||
|
||||
// CheckUserResult 是 S2C 889 的保守构造输入。
|
||||
// 三组列表分别进入对象 +0xA4/+0xB8/+0xCC;第一组还会派生 +0xE0 显示项。
|
||||
type CheckUserResult struct {
|
||||
Header CheckUserHeader
|
||||
FirstRows [][12]byte
|
||||
SecondRows [][12]byte
|
||||
StateRows [][5]byte
|
||||
}
|
||||
|
||||
// BuildCheckUserResultBody 构造 S2C 889:25 字节头 + 三组 count/raw 列表。
|
||||
// 该构造器不表示业务成功;是否发送必须由公共频道/raid owner 按真实状态决定。
|
||||
func BuildCheckUserResultBody(result CheckUserResult) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(result.Header.Mode)
|
||||
writer.writeUint32(result.Header.Field4)
|
||||
writer.writeUint32(result.Header.Field8)
|
||||
writer.writeByte(result.Header.Field12)
|
||||
writer.writeRaw(result.Header.Raw13To20[:])
|
||||
writer.writeUint32(result.Header.Field21To24)
|
||||
writeCheckUserRows12(&writer, result.FirstRows)
|
||||
writeCheckUserRows12(&writer, result.SecondRows)
|
||||
writer.writeUint32(uint32(len(result.StateRows)))
|
||||
for _, row := range result.StateRows {
|
||||
writer.writeRaw(row[:])
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
func writeCheckUserRows12(writer *packetWriter, rows [][12]byte) {
|
||||
writer.writeUint32(uint32(len(rows)))
|
||||
for _, row := range rows {
|
||||
writer.writeRaw(row[:])
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRequestMembersResultBody 构造 S2C 888:raw[5] = u32 result + u8 flag。
|
||||
// 该构造器只固定 EXE 读取顺序,不表示默认允许或成功。
|
||||
func BuildRequestMembersResultBody(result uint32, flag byte) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(result)
|
||||
writer.writeByte(flag)
|
||||
return writer.bytes()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package raid
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RaidClearAnnouncementMsgID is the class-0 notification the current EXE renders
|
||||
// as the server's raid clear announcement.
|
||||
//
|
||||
// The handler sub_1D5D350 (registered for 626 / 0x272) reads
|
||||
//
|
||||
// u32 mode, dstr raidName, dstr leaderName, u8 solo
|
||||
//
|
||||
// and then formats one of the client's own strings with those two arguments,
|
||||
// drawing the result as a chat line in colour 0xFF0061FF by calling
|
||||
// sub_2728160(text, 0, 0, 0, 0, 0x1E, 0xFF0061FF). That colour is ABGR --
|
||||
// RGB(255, 97, 0), the orange the announcement uses:
|
||||
//
|
||||
// solo == 0, mode 0 -> 60454 太棒了! 热烈祝贺[%s]攻坚队(队长[%s])成功击杀第七使徒安徒恩!
|
||||
// solo == 0, mode 1 -> 66985 (Luke, team)
|
||||
// solo == 0, mode 2 -> 75517 强大的%s攻坚队队长%s和队员们破坏了量子时空传送站! 恭喜!
|
||||
// solo == 1, mode 0 -> 46877 伟大的战士%s成功讨伐了安徒恩, 请大家祝贺。
|
||||
// solo == 1, mode 1 -> 67052 伟大的战士%s破坏了卢克的实验室。 恭喜!
|
||||
//
|
||||
// The solo strings take one argument and the client passes only leaderName, so a
|
||||
// solo raid puts the warrior's own name there.
|
||||
const RaidClearAnnouncementMsgID uint16 = 0x0272
|
||||
|
||||
// The three announcement kinds the client's own switch understands.
|
||||
const (
|
||||
RaidClearKindAnton uint32 = 0
|
||||
RaidClearKindLuke uint32 = 1
|
||||
RaidClearKindSpiral uint32 = 2
|
||||
)
|
||||
|
||||
// raidClearAnnouncementArgumentLimit is the reader's own bound: both arguments go
|
||||
// through sub_3457D20 with a 0x100 cap, and a longer declared length makes it fall
|
||||
// back to an empty string rather than truncating, so it is enforced here.
|
||||
const raidClearAnnouncementArgumentLimit = 0x100
|
||||
|
||||
// RaidClearAnnouncement is one 0x0272 body.
|
||||
type RaidClearAnnouncement struct {
|
||||
// Kind selects the raid whose string the client formats.
|
||||
Kind uint32
|
||||
// RaidName is the first %s: the raid's own name.
|
||||
RaidName []byte
|
||||
// LeaderName is the second %s: the raid leader, or the warrior in solo mode.
|
||||
LeaderName []byte
|
||||
// Solo switches to the single-player text (46877 / 67052), which ignores
|
||||
// RaidName.
|
||||
Solo bool
|
||||
}
|
||||
|
||||
// BuildRaidClearAnnouncementBody encodes the 0x0272 body. The strings are raw
|
||||
// client-encoding bytes exactly like every other dstr on this wire.
|
||||
func BuildRaidClearAnnouncementBody(announcement RaidClearAnnouncement) ([]byte, error) {
|
||||
if len(announcement.RaidName) > raidClearAnnouncementArgumentLimit {
|
||||
return nil, fmt.Errorf("raid clear announcement name is %d bytes, the client reads at most %d",
|
||||
len(announcement.RaidName), raidClearAnnouncementArgumentLimit)
|
||||
}
|
||||
if len(announcement.LeaderName) > raidClearAnnouncementArgumentLimit {
|
||||
return nil, fmt.Errorf("raid clear announcement leader is %d bytes, the client reads at most %d",
|
||||
len(announcement.LeaderName), raidClearAnnouncementArgumentLimit)
|
||||
}
|
||||
writer := packetWriter{}
|
||||
writer.writeUint32(announcement.Kind)
|
||||
writer.writeRawDstr(announcement.RaidName)
|
||||
writer.writeRawDstr(announcement.LeaderName)
|
||||
if announcement.Solo {
|
||||
writer.writeByte(1)
|
||||
} else {
|
||||
writer.writeByte(0)
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package raid 保存当前 NoPack 已闭合的攻坚队协议结构。
|
||||
//
|
||||
// 普通 PARTY_INFO 仍是 4 人小队;攻坚队是独立的 20 人大名单,
|
||||
// 小组字段在攻坚开始时再映射成普通队伍。
|
||||
package raid
|
||||
@@ -0,0 +1,837 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FiendWarUnit struct {
|
||||
ID uint32
|
||||
Type byte
|
||||
}
|
||||
type FiendWarAreaState struct {
|
||||
Enemy, Monster FiendWarUnit
|
||||
Group byte
|
||||
Warning bool
|
||||
Deadline time.Time
|
||||
}
|
||||
type FiendWarTimerKey struct {
|
||||
Area int
|
||||
Kind string
|
||||
}
|
||||
type FiendWarTimer struct{ StartedAt, Deadline time.Time }
|
||||
type FiendWarEvent struct {
|
||||
Kind string
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
type FiendWarBattle struct {
|
||||
Config *FiendWarBattleConfig
|
||||
Symbols map[uint32]int
|
||||
Areas map[int]FiendWarAreaState
|
||||
Timers map[FiendWarTimerKey]FiendWarTimer
|
||||
DungeonStates map[int]string
|
||||
GroupAreas map[byte]int
|
||||
MemberAreas map[uint16]int
|
||||
RecoveryUntil map[uint16]time.Time
|
||||
ImprisonCounts map[uint16]int
|
||||
MerchantArea int
|
||||
MerchantKind int
|
||||
MerchantDuration time.Duration
|
||||
NextUnitID uint32
|
||||
Complete, Failed bool
|
||||
// BossWindowDone[side] = 这一侧已经就**本轮**破防窗口报过尾(kind=2 带上自己
|
||||
// 那一侧 BOSS 的剩余血量)。两侧都报齐之后才判定:都归零 = 通关,
|
||||
// 否则血量均摊、整轮重开。旧字段名叫 BossPatternDone,语义是"两侧都报了同一颗球"
|
||||
// —— 那个"锁步"模型是错的(见 fiendWarCloneStates 上面的说明)。
|
||||
BossWindowDone [2]bool
|
||||
// PickClone 只有测试会注入:官方这一版四颗球的出场顺序是随机的,
|
||||
// 但测试需要钉住序列。nil → rand.IntN。
|
||||
PickClone func(candidates []int) int
|
||||
Retreats map[byte]int
|
||||
// FixtureMazes 是"这个设施区域的领主是哪一张图",key = [AREA INDEX]。
|
||||
//
|
||||
// 它**不是抽签结果,是固定的**。操作员 2026-10-01 复核历史截图后定的口径:
|
||||
//
|
||||
// 「我所说的这些格子里的怪物是固定的。雷达打完前显示问号、服务端随机抽没问题;
|
||||
// 雷达打完之后客户端就是这么画的,服务端也理应按照客户端画的去让怪物真正
|
||||
// 百分百地出现 —— 换句话说,雷达打完前服务端随机抽签生效,打完后就不再抽签。」
|
||||
//
|
||||
// 客户端那张 kind→脸 的表是按格子位置排的(上哨=宾柏 / 中哨=薛丁恶 / 下哨=克鲁瑟,
|
||||
// 兵站本营同理),而战况表上的 type 也正是这个位置 kind。所以服务端只要把
|
||||
// "实际进的那张图"钉在同一位置上,表上画的那只与进图打的那只就是同一只,
|
||||
// 不需要(也不允许)靠随机去碰。见 fiendWarFixtureMazeForKind。
|
||||
//
|
||||
// 只有 maze 张数 > 1 的设施区域才登记;雷达/炮塔/传送站只有一张图,表里没有
|
||||
// 它们,取用时退回 0。
|
||||
FixtureMazes map[int]int
|
||||
pending []FiendWarEvent
|
||||
}
|
||||
|
||||
// fiendWarFixtureMazeForKind 把设施的位置翻译成它**固定**的那张 maze。
|
||||
//
|
||||
// 上哨 / 上兵营 → 0 中哨 / 中兵营 → 1 下哨 / 下兵营 → 2
|
||||
//
|
||||
// 顺序与 FiendWarFixtureBossIDs 一致(那张表是从 PVF 的 maze 顺序抄下来的):
|
||||
// 0 = 人类背叛者:宾柏 / 光之恶魔:欧格里,1 = 引力恶魔:薛丁恶 / 暗之恶魔:艾森,
|
||||
// 2 = 宇宙粉碎机:克鲁瑟 / 重力恶魔:白斯。
|
||||
func fiendWarFixtureMazeForKind(kind string) (int, bool) {
|
||||
switch kind {
|
||||
case "top outpost", "top command center":
|
||||
return 0, true
|
||||
case "mid outpost", "mid command center":
|
||||
return 1, true
|
||||
case "bot outpost", "bot command center":
|
||||
return 2, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func NewFiendWarBattle(config *FiendWarBattleConfig) (*FiendWarBattle, error) {
|
||||
if config == nil || config.Raid == nil || len(config.Areas) == 0 || len(config.Rules) == 0 {
|
||||
return nil, fmt.Errorf("fiendwar battle configuration missing")
|
||||
}
|
||||
b := &FiendWarBattle{Config: config, Symbols: map[uint32]int{}, Areas: map[int]FiendWarAreaState{}, Timers: map[FiendWarTimerKey]FiendWarTimer{}, DungeonStates: map[int]string{}, GroupAreas: map[byte]int{}, Retreats: map[byte]int{}, FixtureMazes: map[int]int{}}
|
||||
b.MemberAreas, b.RecoveryUntil, b.ImprisonCounts = map[uint16]int{}, map[uint16]time.Time{}, map[uint16]int{}
|
||||
for _, id := range config.Symbols {
|
||||
b.Symbols[id] = 0
|
||||
}
|
||||
for id, area := range config.Areas {
|
||||
b.Areas[id] = FiendWarAreaState{}
|
||||
// 多图设施:按位置钉死是哪一张(不抽签)。
|
||||
if bosses := FiendWarFixtureBossIDs[area.Dungeon]; len(bosses) > 1 {
|
||||
if maze, ok := fiendWarFixtureMazeForKind(area.Kind); ok && maze < len(bosses) {
|
||||
b.FixtureMazes[id] = maze
|
||||
}
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// MazeForArea 返回这一格固定用的那张 maze。只有多图设施区域会登记;
|
||||
// 未登记时 ok 为 false,调用方按自己的语义决定(单图副本恒为 0)。
|
||||
func (b *FiendWarBattle) MazeForArea(area int) (int, bool) {
|
||||
if b == nil || b.FixtureMazes == nil {
|
||||
return 0, false
|
||||
}
|
||||
maze, ok := b.FixtureMazes[area]
|
||||
return maze, ok
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Clone() *FiendWarBattle {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := *b
|
||||
c.Symbols = maps.Clone(b.Symbols)
|
||||
c.Areas = maps.Clone(b.Areas)
|
||||
c.Timers = maps.Clone(b.Timers)
|
||||
c.DungeonStates = maps.Clone(b.DungeonStates)
|
||||
c.GroupAreas = maps.Clone(b.GroupAreas)
|
||||
c.MemberAreas = maps.Clone(b.MemberAreas)
|
||||
c.RecoveryUntil = maps.Clone(b.RecoveryUntil)
|
||||
c.ImprisonCounts = maps.Clone(b.ImprisonCounts)
|
||||
c.Retreats = maps.Clone(b.Retreats)
|
||||
c.FixtureMazes = maps.Clone(b.FixtureMazes)
|
||||
c.pending = append([]FiendWarEvent(nil), b.pending...)
|
||||
return &c
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Value(name string) int { return b.Symbols[b.Config.Symbols[name]] }
|
||||
func (b *FiendWarBattle) set(name string, value int) { b.setSymbol(b.Config.Symbols[name], value) }
|
||||
func (b *FiendWarBattle) setSymbol(id uint32, value int) {
|
||||
if id == 0 || b.Symbols[id] == value {
|
||||
return
|
||||
}
|
||||
b.Symbols[id] = value
|
||||
b.pending = append(b.pending, FiendWarEvent{Kind: "on change symbol", ID: int(id)})
|
||||
}
|
||||
|
||||
// Apply processes the PVF's paired rules in source order, then follows only
|
||||
// actual symbol/area changes. A bounded queue rejects cyclic script updates.
|
||||
func (b *FiendWarBattle) Apply(event FiendWarEvent, now time.Time) error {
|
||||
if b.Complete || b.Failed {
|
||||
return nil
|
||||
}
|
||||
b.pending = append(b.pending, event)
|
||||
for count := 0; len(b.pending) > 0; count++ {
|
||||
if count > 4096 {
|
||||
return fmt.Errorf("fiendwar trigger cycle")
|
||||
}
|
||||
e := b.pending[0]
|
||||
b.pending = b.pending[1:]
|
||||
for _, rule := range b.Config.Rules {
|
||||
matched := true
|
||||
for _, c := range rule.Conditions {
|
||||
if !b.matches(c, e) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
for _, a := range rule.Actions {
|
||||
b.act(a, now)
|
||||
}
|
||||
}
|
||||
if b.Complete || b.Failed {
|
||||
b.pending = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) matches(c FiendWarInstruction, e FiendWarEvent) bool {
|
||||
arg := 0
|
||||
if len(c.Args) > 0 {
|
||||
arg = int(c.Args[0].Int)
|
||||
}
|
||||
switch c.Op {
|
||||
case "if":
|
||||
return b.Symbols[c.Symbol] == arg
|
||||
case "less":
|
||||
return b.Symbols[c.Symbol] < arg
|
||||
case "on change symbol":
|
||||
return e.Kind == c.Op && e.ID == int(c.Symbol)
|
||||
case "on change area state", "on giveup area", "on enter dungeon", "on enter map", "on giveup dungeon", "on change dungeon state":
|
||||
return e.Kind == c.Op && e.ID == arg
|
||||
case "check area state":
|
||||
return b.areaState(arg) == c.Args[1].Value
|
||||
case "check dungeon state":
|
||||
return b.DungeonStates[arg] == c.Args[1].Value
|
||||
case "check timer end":
|
||||
return e.Kind == c.Op && e.ID == int(c.Args[1].Int) && e.Name == c.Args[0].Value
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) areaState(id int) string {
|
||||
a := b.Areas[id]
|
||||
if a.Group != 0 {
|
||||
if a.Enemy.ID != 0 {
|
||||
return "user_enemy"
|
||||
}
|
||||
if a.Monster.ID != 0 {
|
||||
return "user_monster"
|
||||
}
|
||||
}
|
||||
if a.Enemy.ID != 0 && a.Monster.ID != 0 {
|
||||
return "enemy_monster"
|
||||
}
|
||||
if a.Enemy.ID != 0 {
|
||||
return "enemy"
|
||||
}
|
||||
if a.Monster.ID != 0 {
|
||||
return "monster"
|
||||
}
|
||||
return "empty"
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) updateArea(id int, a FiendWarAreaState) {
|
||||
previous := b.areaState(id)
|
||||
b.Areas[id] = a
|
||||
current := b.areaState(id)
|
||||
if previous != current {
|
||||
// sub_382A400: empty, enemy, monster, user_enemy, user_monster, enemy_monster.
|
||||
b.Symbols[b.Config.Areas[id].Symbol] = map[string]int{"empty": 0, "enemy": 1, "monster": 2, "user_enemy": 3, "user_monster": 4, "enemy_monster": 5}[current]
|
||||
b.pending = append(b.pending, FiendWarEvent{Kind: "on change area state", ID: id})
|
||||
}
|
||||
count := 0
|
||||
for _, area := range b.Areas {
|
||||
if area.Monster.ID != 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
b.set("alive monster count", count)
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) act(a FiendWarInstruction, now time.Time) {
|
||||
id := 0
|
||||
if len(a.Args) > 0 {
|
||||
id = int(a.Args[0].Int)
|
||||
}
|
||||
switch a.Op {
|
||||
case "set":
|
||||
b.setSymbol(a.Symbol, id)
|
||||
case "set timer":
|
||||
key := FiendWarTimerKey{int(a.Args[1].Int), a.Args[0].Value}
|
||||
b.Timers[key] = FiendWarTimer{now, now.Add(time.Duration(a.Args[2].Int) * time.Second)}
|
||||
case "reset timer":
|
||||
delete(b.Timers, FiendWarTimerKey{int(a.Args[1].Int), a.Args[0].Value})
|
||||
case "create enemy":
|
||||
area := b.Areas[id]
|
||||
if area.Enemy.ID != 0 {
|
||||
return
|
||||
}
|
||||
total := 0
|
||||
for i := 1; i < len(b.Config.EnemyRates); i += 2 {
|
||||
total += b.Config.EnemyRates[i]
|
||||
}
|
||||
roll := rand.IntN(total)
|
||||
kind := 1
|
||||
for i := 0; i < len(b.Config.EnemyRates); i += 2 {
|
||||
roll -= b.Config.EnemyRates[i+1]
|
||||
if roll < 0 {
|
||||
kind = b.Config.EnemyRates[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
b.NextUnitID++
|
||||
area.Enemy = FiendWarUnit{b.NextUnitID, byte(kind)}
|
||||
b.updateArea(id, area)
|
||||
case "move enemy":
|
||||
to := int(a.Args[1].Int)
|
||||
src, dst := b.Areas[id], b.Areas[to]
|
||||
if src.Enemy.ID == 0 || src.Group != 0 {
|
||||
return
|
||||
}
|
||||
if dst.Enemy.ID == 0 {
|
||||
dst.Enemy = src.Enemy
|
||||
}
|
||||
dst.Warning = false
|
||||
src.Enemy = FiendWarUnit{}
|
||||
src.Warning = false
|
||||
delete(b.Timers, FiendWarTimerKey{id, "move_enemy"})
|
||||
delete(b.Timers, FiendWarTimerKey{id, "warning_enemy"})
|
||||
b.updateArea(id, src)
|
||||
b.updateArea(to, dst)
|
||||
case "delete monster":
|
||||
area := b.Areas[id]
|
||||
area.Monster = FiendWarUnit{}
|
||||
b.updateArea(id, area)
|
||||
case "delete all enemy":
|
||||
for _, id := range b.areaIDs() {
|
||||
area := b.Areas[id]
|
||||
area.Enemy = FiendWarUnit{}
|
||||
area.Warning = false
|
||||
b.updateArea(id, area)
|
||||
}
|
||||
for key := range b.Timers {
|
||||
if strings.Contains(key.Kind, "enemy") {
|
||||
delete(b.Timers, key)
|
||||
}
|
||||
}
|
||||
case "warning enemy":
|
||||
area := b.Areas[id]
|
||||
area.Warning = true
|
||||
b.Areas[id] = area
|
||||
case "create monsters":
|
||||
count := 0
|
||||
var available []int
|
||||
for _, id := range b.areaIDs() {
|
||||
area := b.Areas[id]
|
||||
if area.Monster.ID != 0 {
|
||||
count++
|
||||
}
|
||||
// Orcs appear in empty areas. Enemy/monster combat starts when an
|
||||
// army reaches them, not when a spawn overlaps a stationary army.
|
||||
if b.Config.Areas[id].Kind == "" && b.Config.Areas[id].Dungeon > 0 && area.Group == 0 && area.Enemy.ID == 0 && area.Monster.ID == 0 {
|
||||
available = append(available, id)
|
||||
}
|
||||
}
|
||||
for ; count < 3 && len(available) > 0; count++ {
|
||||
i := rand.IntN(len(available))
|
||||
id := available[i]
|
||||
available = append(available[:i], available[i+1:]...)
|
||||
area := b.Areas[id]
|
||||
b.NextUnitID++
|
||||
area.Monster = FiendWarUnit{b.NextUnitID, byte(1 + rand.IntN(2))}
|
||||
b.updateArea(id, area)
|
||||
}
|
||||
case "create merchant":
|
||||
if len(b.Config.MerchantPositions) > 0 {
|
||||
b.MerchantArea = b.Config.MerchantPositions[rand.IntN(len(b.Config.MerchantPositions))]
|
||||
}
|
||||
if len(b.Config.MerchantRates) > 0 {
|
||||
total := 0
|
||||
for _, rate := range b.Config.MerchantRates {
|
||||
total += rate.Weight
|
||||
}
|
||||
if total > 0 {
|
||||
roll := rand.IntN(total)
|
||||
for _, rate := range b.Config.MerchantRates {
|
||||
roll -= rate.Weight
|
||||
if roll < 0 {
|
||||
b.MerchantKind = rate.Kind
|
||||
b.MerchantDuration = time.Duration(rate.DurationMS) * time.Millisecond
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "boss pattern init":
|
||||
b.resetBossPattern()
|
||||
b.set("kick out character", 0)
|
||||
case "clear phase":
|
||||
b.Complete = true
|
||||
case "fail fiendwar":
|
||||
b.Failed = true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) areaIDs() []int {
|
||||
ids := slices.Collect(maps.Keys(b.Config.Areas))
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
func (b *FiendWarBattle) AreaForDungeon(dungeon int) (FiendWarArea, bool) {
|
||||
for _, a := range b.Config.Areas {
|
||||
if a.Dungeon == dungeon {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return FiendWarArea{}, false
|
||||
}
|
||||
func (b *FiendWarBattle) AreaOpen(id int) bool {
|
||||
a, ok := b.Config.Areas[id]
|
||||
if !ok || b.Complete || b.Failed {
|
||||
return false
|
||||
}
|
||||
if strings.HasSuffix(a.Kind, "boss") {
|
||||
return b.Value(a.Kind+" state") == 1
|
||||
}
|
||||
if a.Kind == "radar" || a.Kind == "battery" {
|
||||
return b.Value(a.Kind+" state") == 0
|
||||
}
|
||||
if a.Kind != "" {
|
||||
return b.Value(a.Kind+" state") == 1
|
||||
}
|
||||
return a.Dungeon > 0 && (b.Areas[id].Enemy.ID != 0 || b.Areas[id].Monster.ID != 0)
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Move(group byte, target int) error {
|
||||
if group == 0 || b.Complete || b.Failed {
|
||||
return fmt.Errorf("fiendwar group inactive")
|
||||
}
|
||||
from, ok := b.GroupAreas[group]
|
||||
if !ok {
|
||||
from = b.Config.WaitingArea
|
||||
}
|
||||
if from == target {
|
||||
return nil
|
||||
}
|
||||
if !slices.Contains(b.Config.Areas[from].Next, target) {
|
||||
return fmt.Errorf("fiendwar areas are not adjacent")
|
||||
}
|
||||
// 超时空的区域是**共享战场**,不是小队的格子:
|
||||
//
|
||||
// - 客户端点进已有同伴的区域时自己弹的是「移动后,会与处于当前城镇的同伴
|
||||
// 一同开始战斗。确认要移动吗?」—— 语义就是"进去一起打";
|
||||
// - PVF 的 [AREA INFO] 只有 [MEMBER MAX](本 PVF 每区域 4 人),
|
||||
// **没有"归属小队"这个概念**。
|
||||
//
|
||||
// 所以这里不能按小队独占。人数上限由 MoveMember 的 [MEMBER MAX] 管,
|
||||
// 死锁/占位交给上层(谁是这一区域的战斗单元由 encounter 决定)。
|
||||
// 原实现的两条判断(Areas[target].Group != 0 → "fiendwar area occupied",
|
||||
// 以及 GroupAreas 扫描 → "fiendwar area occupied by another group")
|
||||
// 正是真机上「9999 进了作战区域-01,礼白666 就进不去」的原因。
|
||||
// 原来这里还有一条"站在没打完的区域里不许再走"("fiendwar source area
|
||||
// requires combat")。它已经害过两次真机事故,2026-10-01 这次是第二次:
|
||||
//
|
||||
// 玩家在作战区域 A 里没打,直接走进了有怪的 B —— 客户端**照走不误**(它自己不
|
||||
// 认这条规则),服务端却在 town_move.go 里静默 return,于是出现两种后果:
|
||||
// 1) 客户端的角色在 B,服务端的 MemberAreas 还记着 A —— 两个人明明站在同一格,
|
||||
// runtimeRaidFiendWarAreaParty 却看不到同伴,"同区域一起进图"直接落空;
|
||||
// 2) 之后这个人再按进图(target 还是 B,from 仍是 A)又被同一条拒绝挡下,
|
||||
// 而拒绝路径**一个包都不发** —— 礼白666 就是这样被卡死的。
|
||||
//
|
||||
// 客户端的行为就是官方规则的证据:它能走,说明官方允许走。地图的推进由
|
||||
// [AREA INFO] 的相邻关系 + 设施/BOSS 的 symbol 状态把关(下面那条 BOSS 门禁),
|
||||
// 不需要"踩到怪就必须打完"这条自造规则。
|
||||
if strings.HasSuffix(b.Config.Areas[target].Kind, "boss") && !b.AreaOpen(target) {
|
||||
return fmt.Errorf("fiendwar boss gate closed")
|
||||
}
|
||||
b.GroupAreas[group] = target
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) MemberArea(character uint16, group byte) int {
|
||||
if area, ok := b.MemberAreas[character]; ok {
|
||||
return area
|
||||
}
|
||||
if area, ok := b.GroupAreas[group]; ok {
|
||||
return area
|
||||
}
|
||||
return b.Config.WaitingArea
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) MoveMember(character uint16, group byte, target int, now time.Time) error {
|
||||
area, found := b.Config.Areas[target]
|
||||
if character == 0 || !found {
|
||||
return fmt.Errorf("invalid fiendwar member or area")
|
||||
}
|
||||
if now.Before(b.RecoveryUntil[character]) {
|
||||
return fmt.Errorf("fiendwar member recovering")
|
||||
}
|
||||
occupants := 0
|
||||
for other, location := range b.MemberAreas {
|
||||
if other != character && location == target {
|
||||
occupants++
|
||||
}
|
||||
}
|
||||
if area.MemberMax > 0 && occupants >= area.MemberMax {
|
||||
return fmt.Errorf("fiendwar area member limit reached")
|
||||
}
|
||||
previous, exists := b.GroupAreas[group]
|
||||
b.GroupAreas[group] = b.MemberArea(character, group)
|
||||
err := b.Move(group, target)
|
||||
if exists {
|
||||
b.GroupAreas[group] = previous
|
||||
} else {
|
||||
delete(b.GroupAreas, group)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.MemberAreas == nil {
|
||||
b.MemberAreas = make(map[uint16]int)
|
||||
}
|
||||
b.MemberAreas[character] = target
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) ImprisonMember(character uint16, now time.Time) {
|
||||
if b.MemberAreas == nil {
|
||||
b.MemberAreas = make(map[uint16]int)
|
||||
}
|
||||
if b.RecoveryUntil == nil {
|
||||
b.RecoveryUntil = make(map[uint16]time.Time)
|
||||
}
|
||||
if b.ImprisonCounts == nil {
|
||||
b.ImprisonCounts = make(map[uint16]int)
|
||||
}
|
||||
durations := b.Config.DieImprisonSeconds
|
||||
if len(durations) == 0 {
|
||||
return
|
||||
}
|
||||
seconds := durations[min(b.ImprisonCounts[character], len(durations)-1)]
|
||||
b.ImprisonCounts[character]++
|
||||
b.MemberAreas[character] = b.Config.WaitingArea
|
||||
b.RecoveryUntil[character] = now.Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Enter(group byte, dungeon int, now time.Time) error {
|
||||
a, ok := b.AreaForDungeon(dungeon)
|
||||
if !ok || b.GroupAreas[group] != a.Index || !b.AreaOpen(a.Index) || b.Areas[a.Index].Group != 0 {
|
||||
return fmt.Errorf("fiendwar dungeon admission rejected")
|
||||
}
|
||||
area := b.Areas[a.Index]
|
||||
delete(b.Retreats, group)
|
||||
delete(b.DungeonStates, dungeon)
|
||||
if strings.HasSuffix(a.Kind, "boss") {
|
||||
b.set("kick out character", 0)
|
||||
}
|
||||
area.Group = group
|
||||
area.Deadline = now.Add(time.Duration(a.LimitSeconds) * time.Second)
|
||||
area.Warning = false
|
||||
b.updateArea(a.Index, area)
|
||||
delete(b.Timers, FiendWarTimerKey{a.Index, "move_enemy"})
|
||||
delete(b.Timers, FiendWarTimerKey{a.Index, "warning_enemy"})
|
||||
return b.Apply(FiendWarEvent{Kind: "on enter dungeon", ID: dungeon}, now)
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Clear(dungeon int, now time.Time) error {
|
||||
a, ok := b.AreaForDungeon(dungeon)
|
||||
if !ok || b.Areas[a.Index].Group == 0 {
|
||||
return fmt.Errorf("fiendwar dungeon not occupied")
|
||||
}
|
||||
// 每个 BOSS 房的门槛只看自己这一侧:PVF 的两条推进规则是独立的
|
||||
// ([ON CHANGE DUNGEON STATE] 4978 … [IF TOP BOSS STATE] 9 → 10,
|
||||
// 4953 那条同理),谁先倒下谁先记 10,两个都到 10 才 [CLEAR PHASE]。
|
||||
// 原来这里额外要求**双方**的 hp 都已归零,等于规定两个 BOSS 必须同时死,
|
||||
// 分队先后击破时先倒的那一侧永远记不上账。
|
||||
if strings.HasSuffix(a.Kind, "boss") && (b.Value(a.Kind+" state") != 9 || b.Value(a.Kind+" hp") != 0) {
|
||||
return fmt.Errorf("fiendwar boss mechanisms unfinished")
|
||||
}
|
||||
area := b.Areas[a.Index]
|
||||
area.Enemy = FiendWarUnit{}
|
||||
area.Monster = FiendWarUnit{}
|
||||
area.Group = 0
|
||||
area.Deadline = time.Time{}
|
||||
b.updateArea(a.Index, area)
|
||||
if a.Kind == "radar" || a.Kind == "battery" {
|
||||
b.set(a.Kind+" state", 1)
|
||||
} else if a.Kind != "" && !strings.HasSuffix(a.Kind, "boss") {
|
||||
b.set(a.Kind+" state", 0)
|
||||
}
|
||||
b.DungeonStates[dungeon] = "clear"
|
||||
return b.Apply(FiendWarEvent{Kind: "on change dungeon state", ID: dungeon}, now)
|
||||
}
|
||||
|
||||
// ClearClientBossRoom is Clear for a BOSS room whose death the CLIENT proved.
|
||||
//
|
||||
// 背景(2026-10-01 真机,packet_log-20261001-007):
|
||||
//
|
||||
// 这个客户端在 BOSS 房里只发一种 op1511 报文 —— kind=0(BOSS 血量百分比),
|
||||
// 每 2 秒一条,value 恒为 100,直到 BOSS 死亡那一刻为止;全程 34 条,一条
|
||||
// kind=1(机制)都没有。而 PVF 的 BOSS 状态机只能靠 kind=1 从 5 走到 9
|
||||
// (BossReport case 1),所以 `topic boss state` 永远停在 2/4/5,
|
||||
// Clear 必然以 "fiendwar boss mechanisms unfinished" 失败,
|
||||
// 通关记账整段被跳过 —— 玩家看到的就是「BOSS 打死了,什么都没发生」:
|
||||
// 没有 1453{3,0}、没有结算、没有回城,两个人一直卡在 BOSS 房里。
|
||||
//
|
||||
// 结论:本客户端下 PVF 的 BOSS 机制握手是不可达的。副本通关本身已经是
|
||||
// 权威事实(op39 BOSS 死亡 → op115/op117 → ordinary_final_clear_committed),
|
||||
// 所以这里用它当作"这一侧 BOSS 已死"的证据,把该侧的机制收尾到 PVF 允许
|
||||
// 通关的那个状态(state 9 + hp 0),再走原有的 Clear —— 于是
|
||||
// [IF … BOSS STATE 9] → 10、两个 10 → [CLEAR PHASE] 全部照 PVF 走。
|
||||
//
|
||||
// 非 BOSS 区域逐字走 Clear;客户端真的报过 kind=1 的场合(state 已经是 9)
|
||||
// 这里不改任何状态,行为与原来完全一致。
|
||||
func (b *FiendWarBattle) ClearClientBossRoom(dungeon int, now time.Time) error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("fiendwar battle missing")
|
||||
}
|
||||
a, ok := b.AreaForDungeon(dungeon)
|
||||
if !ok || !strings.HasSuffix(a.Kind, "boss") {
|
||||
return b.Clear(dungeon, now)
|
||||
}
|
||||
if b.Areas[a.Index].Group == 0 {
|
||||
return fmt.Errorf("fiendwar dungeon not occupied")
|
||||
}
|
||||
if b.Value(a.Kind+" state") != 9 {
|
||||
// 机制条归零、这一侧血量归零,然后把它推进到"伤害窗口已开"的 9。
|
||||
b.set("boss pattern state", 0)
|
||||
b.set(a.Kind+" hp", 0)
|
||||
b.set(a.Kind+" view hp", 0)
|
||||
b.set(a.Kind+" state", 9)
|
||||
}
|
||||
return b.Clear(dungeon, now)
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Abandon(dungeon int, now time.Time) error {
|
||||
a, ok := b.AreaForDungeon(dungeon)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown fiendwar dungeon")
|
||||
}
|
||||
area := b.Areas[a.Index]
|
||||
group := area.Group
|
||||
if group == 0 {
|
||||
return nil
|
||||
}
|
||||
area.Group = 0
|
||||
area.Deadline = time.Time{}
|
||||
b.updateArea(a.Index, area)
|
||||
b.Retreats[group] = dungeon
|
||||
b.pending = append(b.pending, FiendWarEvent{Kind: "on giveup area", ID: a.Index})
|
||||
if err := b.Apply(FiendWarEvent{Kind: "on giveup dungeon", ID: dungeon}, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(a.Kind, "boss") {
|
||||
// PVF requests both rooms to retreat when either boss run is abandoned.
|
||||
for _, other := range b.areaIDs() {
|
||||
if strings.HasSuffix(b.Config.Areas[other].Kind, "boss") && b.Areas[other].Group != 0 {
|
||||
if err := b.Abandon(b.Config.Areas[other].Dungeon, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
b.BossWindowDone = [2]bool{}
|
||||
b.set("boss pattern state", 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FiendWarBattle) Tick(now time.Time) error {
|
||||
for _, id := range b.areaIDs() {
|
||||
a := b.Areas[id]
|
||||
if a.Group != 0 && !a.Deadline.IsZero() && !now.Before(a.Deadline) {
|
||||
if err := b.Abandon(b.Config.Areas[id].Dungeon, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := slices.Collect(maps.Keys(b.Timers))
|
||||
slices.SortFunc(keys, func(a, c FiendWarTimerKey) int {
|
||||
if a.Area != c.Area {
|
||||
return a.Area - c.Area
|
||||
}
|
||||
return strings.Compare(a.Kind, c.Kind)
|
||||
})
|
||||
for _, key := range keys {
|
||||
timer, ok := b.Timers[key]
|
||||
if ok && !now.Before(timer.Deadline) {
|
||||
delete(b.Timers, key)
|
||||
if err := b.Apply(FiendWarEvent{Kind: "check timer end", ID: key.Area, Name: key.Kind}, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 罗什(宇宙恶魔)的四颗属性球
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 一切以**客户端自己的表**为准(`DNF.exe` 0x9D80A0 那张 if/else 链,反汇编见
|
||||
// `_re_badge_20261001/boss_native.py`):客户端把"我这一侧现在该打哪颗球"读成
|
||||
// `top boss state` / `bot boss state`(PVF 符号 92/93),取值与分身怪物一一对应:
|
||||
//
|
||||
// 5 → 110996 cosmofiend_planet_crystal(宝石/虚无)
|
||||
// 6 → 110997 cosmofiend_planet_fire (火)
|
||||
// 7 → 110998 cosmofiend_planet_water (水/冰,动画是 cosmofiend_planet_ice.ani)
|
||||
// 8 → 110999 cosmofiend_planet_wind (风)
|
||||
//
|
||||
// 9 = 这一侧没有球可打了(本体出现);10 = 这一侧已通关(PVF rule 137/139)。
|
||||
//
|
||||
// **球池是两队共享的**:`boss pattern state`(符号 94)的四个十进制位就是四颗球,
|
||||
// 1 = 那颗球还在。客户端拿它画罗什身边那几颗球(自己正在打的那颗不画)。
|
||||
// 所以两侧**不必打同一颗球** —— 官方攻略「超时空漩涡宇宙恶魔罗什攻略」(3DM/COLG)
|
||||
// 的原话:
|
||||
//
|
||||
// 「罗什四属性球出现后开始随机选择:黑暗队伍视角第一个分身为虚无、
|
||||
// 纯白队伍视角第一个分身为火 … 当黑暗队伍消灭虚无分身:罗什选择了风分身、
|
||||
// 纯白队伍视角虚无球消失 … 当纯白队伍消灭冰分身 → 两队视角所有球消失本体出现」
|
||||
//
|
||||
// 也就是:每一侧**各拿一颗**(两颗不同),谁打掉自己那颗,那颗球从池子里永久消失、
|
||||
// 该侧再从剩下的球里补一颗;四颗全清 → 两侧都到 9 → 本体破防(官方:14.8 秒,
|
||||
// 本机实测正好 15 秒)。破防结束两队**血量均摊(向下取整)**,然后整轮重开。
|
||||
var fiendWarCloneStates = [4]int{5, 6, 7, 8}
|
||||
|
||||
// fiendWarCloneWeights 是机制条 boss pattern state 的四个十进制位(1=球还在)。
|
||||
// 位与分身的对应关系只影响客户端画哪一颗球灭掉,不影响判定。
|
||||
var fiendWarCloneWeights = [4]int{1000, 100, 10, 1}
|
||||
|
||||
// cloneInPattern 判断某颗球还在不在机制条上。
|
||||
func cloneInPattern(pattern, state int) bool {
|
||||
if state < 5 || state > 8 {
|
||||
return false
|
||||
}
|
||||
weight := fiendWarCloneWeights[state-5]
|
||||
return (pattern/weight)%10 == 1
|
||||
}
|
||||
|
||||
func bossPatternSideName(side int) string {
|
||||
if side == 1 {
|
||||
return "bot boss"
|
||||
}
|
||||
return "top boss"
|
||||
}
|
||||
|
||||
// resetBossPattern 重开一轮:四颗球复原、两侧各分一颗**不同**的球。
|
||||
//
|
||||
// 调用点有两个:PVF 的 `[BOSS PATTERN INIT]`(两队都进了 BOSS 房)和"破防窗口
|
||||
// 两队没打完、血量均摊之后"。
|
||||
func (b *FiendWarBattle) resetBossPattern() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.set("boss pattern state", 1111)
|
||||
b.BossWindowDone = [2]bool{}
|
||||
// 先把两侧都清空,再各分一颗 —— 分配时会避开对面手上那颗。
|
||||
b.set("top boss state", 0)
|
||||
b.set("bot boss state", 0)
|
||||
b.assignBossPatternClone(0)
|
||||
b.assignBossPatternClone(1)
|
||||
}
|
||||
|
||||
// assignBossPatternClone 从共享球池里给这一侧补一颗球。
|
||||
//
|
||||
// 池子 = 机制条上还亮着的球;**对面正拿着的那颗不给**(两侧同时各打一颗不同的)。
|
||||
// 没有可给的了(池子空了,或只剩对面手上那颗)→ 该侧置 9:本体出现,
|
||||
// 这一侧转入"等对面收尾"(官方攻略里的黄地板等待)。
|
||||
func (b *FiendWarBattle) assignBossPatternClone(side int) {
|
||||
pattern := b.Value("boss pattern state")
|
||||
otherHeld := b.Value(bossPatternSideName(1-side) + " state")
|
||||
candidates := make([]int, 0, len(fiendWarCloneStates))
|
||||
for _, state := range fiendWarCloneStates {
|
||||
if !cloneInPattern(pattern, state) {
|
||||
continue
|
||||
}
|
||||
if state == otherHeld {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, state)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
b.set(bossPatternSideName(side)+" state", 9)
|
||||
return
|
||||
}
|
||||
pick := 0
|
||||
if b.PickClone != nil {
|
||||
pick = b.PickClone(candidates)
|
||||
} else {
|
||||
// 官方这一版(2018 超时空之战)四颗球的出场顺序是随机的:
|
||||
// 攻略原文「分身与BOSS的血量/抗性/机制一致,分身顺序也是随机」。
|
||||
pick = rand.IntN(len(candidates))
|
||||
}
|
||||
if pick < 0 || pick >= len(candidates) {
|
||||
pick = 0
|
||||
}
|
||||
b.set(bossPatternSideName(side)+" state", candidates[pick])
|
||||
}
|
||||
|
||||
// Boss reports are accepted only after the bridge binds a live dungeon host.
|
||||
// A zero HP report never grants a clear; the dungeon death owner must follow.
|
||||
func (b *FiendWarBattle) BossReport(dungeon, kind, value int, now time.Time) error {
|
||||
a, ok := b.AreaForDungeon(dungeon)
|
||||
if !ok || !strings.HasSuffix(a.Kind, "boss") || b.Areas[a.Index].Group == 0 {
|
||||
return fmt.Errorf("fiendwar boss host missing")
|
||||
}
|
||||
side := 0
|
||||
if a.Kind == "bot boss" {
|
||||
side = 1
|
||||
}
|
||||
state := b.Value(a.Kind + " state")
|
||||
switch kind {
|
||||
case 0:
|
||||
// BOSS 血量是客户端自己的 HUD 事实,任何"这一侧 BOSS 房被占着"的时刻都
|
||||
// 应当被接收:原来只允许 5..9,结果这个客户端(只会发 kind=0)的每一条
|
||||
// 上报都被 "invalid fiendwar boss HP" 拒掉,战况表上的 BOSS 血量永远是
|
||||
// 初始值,与实际不符。上界 9 保留 —— 10 是"该侧已通关"。
|
||||
if state < 1 || state > 9 || value < 0 || value > 100 {
|
||||
return fmt.Errorf("invalid fiendwar boss HP")
|
||||
}
|
||||
if state == 9 && value < b.Value(a.Kind+" hp") {
|
||||
b.set(a.Kind+" hp", value)
|
||||
}
|
||||
b.set(a.Kind+" view hp", value)
|
||||
case 1:
|
||||
// **只认自己手上那颗**:客户端报的 value 就是它这一侧的 boss state。
|
||||
// 打掉之后那颗球从共享池里永久消失,这一侧再从池子里补一颗;
|
||||
// 池子空了(四颗都清)→ 两侧都到 9,本体破防开始。
|
||||
if state < 5 || state > 8 || value != state || !cloneInPattern(b.Value("boss pattern state"), state) {
|
||||
return fmt.Errorf("stale fiendwar boss mechanism")
|
||||
}
|
||||
b.set("boss pattern state", b.Value("boss pattern state")-fiendWarCloneWeights[state-5])
|
||||
b.assignBossPatternClone(side)
|
||||
case 2:
|
||||
if state != 9 || value < 0 || value > 100 {
|
||||
return fmt.Errorf("invalid fiendwar boss damage-window report")
|
||||
}
|
||||
b.set(a.Kind+" hp", min(value, b.Value(a.Kind+" hp")))
|
||||
b.BossWindowDone[side] = true
|
||||
if b.BossWindowDone[0] && b.BossWindowDone[1] {
|
||||
topHP, botHP := b.Value("top boss hp"), b.Value("bot boss hp")
|
||||
if topHP != 0 || botHP != 0 {
|
||||
// 官方攻略:「击杀所有属性分身后,全团拥有15秒输出时间,15秒内两队将
|
||||
// 各自的BOSS击杀即可顺利通关,如果未击杀成功,则会将BOSS剩余血量
|
||||
// **均摊给两个队伍**,同上机制重新再打一轮即可。」
|
||||
// 另一份攻略补了取整口径:「破防结束后两队会平衡血量,结果**向下取整**;
|
||||
// 相应也会出现一队0%另一队1%,从而直接把1%平衡掉的情况。」
|
||||
balanced := (topHP + botHP) / 2
|
||||
b.set("top boss hp", balanced)
|
||||
b.set("top boss view hp", balanced)
|
||||
b.set("bot boss hp", balanced)
|
||||
b.set("bot boss view hp", balanced)
|
||||
// 均摊之后还有血 → 整轮重开(球池复原、两侧重新分球);
|
||||
// 均摊成 0(0% / 1% 那一档)→ 两边本体都算死了,不再重开,
|
||||
// 交给客户端证死那条路收尾。
|
||||
if balanced != 0 {
|
||||
b.resetBossPattern()
|
||||
}
|
||||
}
|
||||
b.BossWindowDone = [2]bool{}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown fiendwar boss operation")
|
||||
}
|
||||
return b.Apply(FiendWarEvent{}, now)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type FiendWarArea struct {
|
||||
Index, WorldMap, Dungeon, LimitSeconds, MemberMax int
|
||||
Symbol uint32
|
||||
Kind string
|
||||
Next []int
|
||||
}
|
||||
|
||||
type FiendWarInstruction struct {
|
||||
Op string
|
||||
Symbol uint32
|
||||
Args []dnfpvf.Token
|
||||
}
|
||||
|
||||
type FiendWarRule struct {
|
||||
Conditions, Actions []FiendWarInstruction
|
||||
}
|
||||
|
||||
type FiendWarMerchantRate struct {
|
||||
Kind, Weight, DurationMS int
|
||||
}
|
||||
|
||||
type FiendWarMerchantPosition struct {
|
||||
Area, X, Y int
|
||||
}
|
||||
|
||||
type FiendWarBattleConfig struct {
|
||||
Raid *AntonConfig
|
||||
WaitingTown, WaitingArea int
|
||||
Areas map[int]FiendWarArea
|
||||
Symbols map[string]uint32
|
||||
Rules []FiendWarRule
|
||||
EnemyRates []int
|
||||
MerchantPositions []int
|
||||
MerchantRates []FiendWarMerchantRate
|
||||
MerchantPositionRows []FiendWarMerchantPosition
|
||||
DieImprisonSeconds []int
|
||||
DailyAccountEnterLimit int
|
||||
WaitingNPCs []int
|
||||
}
|
||||
|
||||
// Native sub_382A910. These are timer kinds, not packet opcodes.
|
||||
var FiendWarTimerKinds = map[string]byte{
|
||||
"move_enemy": 0, "create_monster": 1, "activate_radar": 2,
|
||||
"activate_battery": 3, "top_create_enemy": 4, "mid_create_enemy": 5,
|
||||
"bot_create_enemy": 6, "top_restore_center": 7, "mid_restore_center": 8,
|
||||
"bot_restore_center": 9, "warning_enemy": 10, "delete_monster": 11,
|
||||
}
|
||||
|
||||
func parseFiendWarBattle(recruitment *FiendWarConfig, doc, symbolDoc *dnfpvf.Document) (*FiendWarBattleConfig, error) {
|
||||
c := &FiendWarBattleConfig{Areas: make(map[int]FiendWarArea), Symbols: make(map[string]uint32)}
|
||||
// The .symbol file pairs an integer with the following section token.
|
||||
for i, token := range symbolDoc.Tokens {
|
||||
if token.Kind != dnfpvf.TokenSection {
|
||||
continue
|
||||
}
|
||||
if i == 0 || symbolDoc.Tokens[i-1].Kind != dnfpvf.TokenInt || symbolDoc.Tokens[i-1].Int <= 0 {
|
||||
return nil, fmt.Errorf("fiendwar symbol %q has no numeric index", token.Value)
|
||||
}
|
||||
name := antonSectionName(token.Value)
|
||||
if _, exists := c.Symbols[name]; exists {
|
||||
return nil, fmt.Errorf("duplicate fiendwar symbol %s", name)
|
||||
}
|
||||
c.Symbols[name] = uint32(symbolDoc.Tokens[i-1].Int)
|
||||
}
|
||||
phases, err := antonFindBlocks(doc, "phase", 0, len(doc.Sections))
|
||||
if err != nil || len(phases) != 1 {
|
||||
return nil, fmt.Errorf("fiendwar phase: %v", err)
|
||||
}
|
||||
end := phases[0].open
|
||||
c.DailyAccountEnterLimit, err = antonReadPositiveIntSection(doc, "daily account enter limit", 0, end)
|
||||
if err != nil || c.DailyAccountEnterLimit > 255 {
|
||||
return nil, fmt.Errorf("invalid fiendwar daily account limit: %v", err)
|
||||
}
|
||||
base := &AntonConfig{PhaseMax: 1, MemberMax: recruitment.MemberMax, StartMinimumMembers: recruitment.StartMinimumMembers, EntryCost: recruitment.EntryCost}
|
||||
if base.StartDelaySeconds, err = antonReadPositiveIntSection(doc, "start delay time", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if base.PhaseTimeoutSeconds, err = antonReadPositiveIntListSection(doc, "phase time over", 0, end, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if base.MovieEndSeconds, err = antonReadPositiveIntListSection(doc, "movie end time", 0, end, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := fiendWarInts(doc, "waiting room", 0, end)
|
||||
if err != nil || len(values) != 2 {
|
||||
return nil, fmt.Errorf("fiendwar waiting room: %v", err)
|
||||
}
|
||||
c.WaitingTown, c.WaitingArea = values[0], values[1]
|
||||
if c.WaitingNPCs, err = fiendWarInts(doc, "waiting room npc", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.DieImprisonSeconds, err = fiendWarInts(doc, "die imprison time", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(c.DieImprisonSeconds) == 0 {
|
||||
return nil, fmt.Errorf("missing fiendwar imprisonment durations")
|
||||
}
|
||||
for _, seconds := range c.DieImprisonSeconds {
|
||||
if seconds <= 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar imprisonment duration")
|
||||
}
|
||||
}
|
||||
if c.EnemyRates, err = fiendWarInts(doc, "enemy create rate", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(c.EnemyRates) == 0 || len(c.EnemyRates)%2 != 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar enemy rates")
|
||||
}
|
||||
for i := 0; i < len(c.EnemyRates); i += 2 {
|
||||
if c.EnemyRates[i] < 1 || c.EnemyRates[i] > 4 || c.EnemyRates[i+1] <= 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar enemy weight")
|
||||
}
|
||||
}
|
||||
if values, err = fiendWarInts(doc, "merchant create pos", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar merchant positions")
|
||||
}
|
||||
for i := 0; i < len(values); i += 3 {
|
||||
row := FiendWarMerchantPosition{Area: values[i], X: values[i+1], Y: values[i+2]}
|
||||
c.MerchantPositionRows = append(c.MerchantPositionRows, row)
|
||||
c.MerchantPositions = append(c.MerchantPositions, row.Area)
|
||||
}
|
||||
if values, err = fiendWarInts(doc, "merchant create rate", 0, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) == 0 || len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar merchant rates")
|
||||
}
|
||||
for i := 0; i < len(values); i += 3 {
|
||||
if values[i] < 0 || values[i+1] <= 0 || values[i+2] <= 0 {
|
||||
return nil, fmt.Errorf("invalid fiendwar merchant rate")
|
||||
}
|
||||
c.MerchantRates = append(c.MerchantRates, FiendWarMerchantRate{Kind: values[i], Weight: values[i+1], DurationMS: values[i+2]})
|
||||
}
|
||||
p := phases[0]
|
||||
name, err := antonParseSingleModeName(doc, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phase := AntonPhase{Name: name}
|
||||
phase.MinimalDungeonClearCount, err = antonReadPositiveIntSection(doc, "minimal dungeon clear count", p.open+1, p.close)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardBlocks, err := antonFindBlocks(doc, "clear reward item", p.open+1, p.close)
|
||||
if err != nil || len(rewardBlocks) != 1 {
|
||||
return nil, fmt.Errorf("fiendwar rewards: %v", err)
|
||||
}
|
||||
if phase.Rewards, err = antonParseRewards(doc, rewardBlocks[0], 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
areaBlocks, err := antonFindBlocks(doc, "area info", p.open+1, p.close)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dungeons := make(map[int]bool)
|
||||
for _, b := range areaBlocks {
|
||||
a := FiendWarArea{}
|
||||
for field, dst := range map[string]*int{"area index": &a.Index, "worldmap index": &a.WorldMap, "dungeon index": &a.Dungeon, "member max": &a.MemberMax} {
|
||||
v, e := fiendWarInts(doc, field, b.open+1, b.close)
|
||||
if e != nil || len(v) != 1 {
|
||||
return nil, fmt.Errorf("fiendwar area %s: %v", field, e)
|
||||
}
|
||||
*dst = v[0]
|
||||
}
|
||||
if a.Index < 1 || a.Index > 127 || a.MemberMax < 1 || a.MemberMax > recruitment.MemberMax {
|
||||
return nil, fmt.Errorf("invalid fiendwar area %+v", a)
|
||||
}
|
||||
if _, exists := c.Areas[a.Index]; exists {
|
||||
return nil, fmt.Errorf("duplicate fiendwar area %d", a.Index)
|
||||
}
|
||||
a.Symbol = c.Symbols[fmt.Sprintf("%d area state", a.Index)]
|
||||
if a.Symbol == 0 {
|
||||
return nil, fmt.Errorf("missing fiendwar area symbol %d", a.Index)
|
||||
}
|
||||
if a.Next, err = fiendWarInts(doc, "move available map", b.open+1, b.close); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := b.open + 1; i < b.close; i++ {
|
||||
field := antonSectionName(doc.Sections[i].Name)
|
||||
if strings.HasSuffix(field, " area") && field != "on giveup area" {
|
||||
a.Kind = strings.TrimSuffix(field, " area")
|
||||
}
|
||||
}
|
||||
if a.Dungeon > 0 {
|
||||
if dungeons[a.Dungeon] {
|
||||
return nil, fmt.Errorf("duplicate fiendwar dungeon %d", a.Dungeon)
|
||||
}
|
||||
dungeons[a.Dungeon] = true
|
||||
if a.LimitSeconds, err = antonReadPositiveIntSection(doc, "dungeon limit time", b.open+1, b.close); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Each area clear is one completed dungeon, not Anton's HP counter.
|
||||
phase.Dungeons = append(phase.Dungeons, AntonDungeonInfo{Index: uint32(a.Dungeon), InitState: AntonDungeonHide, HP: 1, PartyMax: 1})
|
||||
}
|
||||
c.Areas[a.Index] = a
|
||||
}
|
||||
if _, ok := c.Areas[c.WaitingArea]; !ok {
|
||||
return nil, fmt.Errorf("missing fiendwar waiting area")
|
||||
}
|
||||
for _, a := range c.Areas {
|
||||
for _, n := range a.Next {
|
||||
if _, ok := c.Areas[n]; !ok {
|
||||
return nil, fmt.Errorf("unknown fiendwar adjacent area %d", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
triggers, err := antonFindBlocks(doc, "trigger", p.open+1, p.close)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
behaviors, err := antonFindBlocks(doc, "behavior", p.open+1, p.close)
|
||||
if err != nil || len(triggers) != len(behaviors) || len(triggers) == 0 {
|
||||
return nil, fmt.Errorf("fiendwar rule pairs: %v", err)
|
||||
}
|
||||
for i, t := range triggers {
|
||||
b := behaviors[i]
|
||||
if t.close+1 != b.open {
|
||||
return nil, fmt.Errorf("fiendwar unpaired trigger %d", i)
|
||||
}
|
||||
conditions, e := c.parseInstructions(doc, t, true)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
actions, e := c.parseInstructions(doc, b, false)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
c.Rules = append(c.Rules, FiendWarRule{conditions, actions})
|
||||
}
|
||||
base.Phases = []AntonPhase{phase}
|
||||
c.Raid = base
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func fiendWarInts(doc *dnfpvf.Document, name string, start, end int) ([]int, error) {
|
||||
i, err := antonUniqueSection(doc, name, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := antonSectionTokens(doc, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([]int, len(tokens))
|
||||
for j, t := range tokens {
|
||||
values[j], err = antonIntToken(t, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (c *FiendWarBattleConfig) parseInstructions(doc *dnfpvf.Document, b antonSectionBlock, condition bool) ([]FiendWarInstruction, error) {
|
||||
var out []FiendWarInstruction
|
||||
for i := b.open + 1; i < b.close; i++ {
|
||||
name := antonSectionName(doc.Sections[i].Name)
|
||||
args, err := antonSectionTokens(doc, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst := FiendWarInstruction{Op: name, Args: args}
|
||||
if name == "if" || name == "set" || name == "on change symbol" {
|
||||
i++
|
||||
if i >= b.close {
|
||||
return nil, fmt.Errorf("fiendwar missing symbol after %s", name)
|
||||
}
|
||||
inst.Symbol = c.Symbols[antonSectionName(doc.Sections[i].Name)]
|
||||
if inst.Symbol == 0 {
|
||||
return nil, fmt.Errorf("unknown fiendwar symbol %s", doc.Sections[i].Name)
|
||||
}
|
||||
inst.Args, err = antonSectionTokens(doc, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "if" && len(inst.Args) == 0 {
|
||||
i++
|
||||
if i >= b.close || antonSectionName(doc.Sections[i].Name) != "<" {
|
||||
return nil, fmt.Errorf("unsupported fiendwar comparison")
|
||||
}
|
||||
inst.Op = "less"
|
||||
inst.Args, err = antonSectionTokens(doc, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
shape := ""
|
||||
if condition {
|
||||
switch inst.Op {
|
||||
case "if", "less", "on change area state", "on giveup area", "on enter dungeon", "on enter map", "on giveup dungeon", "on change dungeon state":
|
||||
shape = "i"
|
||||
case "on change symbol":
|
||||
shape = ""
|
||||
case "check area state", "check dungeon state":
|
||||
shape = "is"
|
||||
case "check timer end":
|
||||
shape = "si"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported fiendwar condition %s", inst.Op)
|
||||
}
|
||||
} else {
|
||||
switch inst.Op {
|
||||
case "set", "create enemy", "delete monster", "warning enemy":
|
||||
shape = "i"
|
||||
case "move enemy":
|
||||
shape = "ii"
|
||||
case "set timer":
|
||||
shape = "sii"
|
||||
case "reset timer":
|
||||
shape = "si"
|
||||
case "create merchant", "create monsters", "delete all enemy", "boss pattern init", "clear phase", "fail fiendwar":
|
||||
shape = ""
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported fiendwar action %s", inst.Op)
|
||||
}
|
||||
}
|
||||
if len(inst.Args) != len(shape) {
|
||||
return nil, fmt.Errorf("fiendwar %s argument count", inst.Op)
|
||||
}
|
||||
for j, k := range shape {
|
||||
if k == 'i' && inst.Args[j].Kind != dnfpvf.TokenInt || k == 's' && inst.Args[j].Kind != dnfpvf.TokenString {
|
||||
return nil, fmt.Errorf("fiendwar %s argument type", inst.Op)
|
||||
}
|
||||
}
|
||||
if strings.Contains(inst.Op, "timer") {
|
||||
if _, ok := FiendWarTimerKinds[inst.Args[0].Value]; !ok {
|
||||
return nil, fmt.Errorf("unknown fiendwar timer")
|
||||
}
|
||||
}
|
||||
out = append(out, inst)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestFiendWarIndependentMovementAndRecovery(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
b := &FiendWarBattle{
|
||||
Config: &FiendWarBattleConfig{WaitingArea: 2, DieImprisonSeconds: []int{20, 30, 50, 80, 120}, Areas: map[int]FiendWarArea{
|
||||
2: {Index: 2, Next: []int{3, 5}}, 3: {Index: 3, Next: []int{2}}, 5: {Index: 5, Next: []int{2, 8}}, 8: {Index: 8, Next: []int{5}},
|
||||
}},
|
||||
Areas: map[int]FiendWarAreaState{}, GroupAreas: map[byte]int{1: 5}, MemberAreas: map[uint16]int{1: 5, 2: 2},
|
||||
}
|
||||
if err := b.MoveMember(2, 1, 3, now); err != nil {
|
||||
t.Fatal("member used leader's area instead of own", err)
|
||||
}
|
||||
if b.MemberArea(1, 1) != 5 || b.MemberArea(2, 1) != 3 {
|
||||
t.Fatal("one movement teleported both members")
|
||||
}
|
||||
if err := b.MoveMember(2, 1, 8, now); err == nil {
|
||||
t.Fatal("accepted a nonadjacent member move")
|
||||
}
|
||||
for _, seconds := range []int{20, 30, 50, 80, 120, 120} {
|
||||
b.ImprisonMember(2, now)
|
||||
until := now.Add(time.Duration(seconds) * time.Second)
|
||||
if b.MemberArea(2, 1) != 2 || b.RecoveryUntil[2] != until {
|
||||
t.Fatal("wrong imprisonment tier", b.RecoveryUntil[2])
|
||||
}
|
||||
if err := b.MoveMember(2, 1, 3, until.Add(-time.Nanosecond)); err == nil {
|
||||
t.Fatal("escaped imprisonment early")
|
||||
}
|
||||
if err := b.MoveMember(2, 1, 3, until); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = until
|
||||
}
|
||||
clone := b.Clone()
|
||||
clone.ImprisonMember(2, now)
|
||||
if b.ImprisonCounts[2] != 6 || b.MemberArea(2, 1) != 3 {
|
||||
t.Fatal("snapshot changed live recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiendWarAreaMemberLimit(t *testing.T) {
|
||||
b := &FiendWarBattle{
|
||||
Config: &FiendWarBattleConfig{WaitingArea: 2, Areas: map[int]FiendWarArea{
|
||||
2: {Index: 2, Next: []int{3}, MemberMax: 8},
|
||||
3: {Index: 3, Next: []int{2}, MemberMax: 4},
|
||||
}},
|
||||
Areas: map[int]FiendWarAreaState{}, GroupAreas: map[byte]int{1: 2},
|
||||
MemberAreas: map[uint16]int{1: 3, 2: 3, 3: 3, 4: 3, 5: 2},
|
||||
}
|
||||
if err := b.MoveMember(5, 1, 3, time.Now()); err == nil || b.MemberAreas[5] != 2 {
|
||||
t.Fatal("fifth member entered a four-member area")
|
||||
}
|
||||
if err := b.MoveMember(1, 1, 3, time.Now()); err != nil {
|
||||
t.Fatal("existing member counted twice", err)
|
||||
}
|
||||
if err := b.MoveMember(1, 1, 2, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.MoveMember(5, 1, 3, time.Now()); err != nil {
|
||||
t.Fatal("released area slot stayed occupied", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 站在没打完的区域里也必须能继续走。
|
||||
//
|
||||
// 原来 Move 里有一条"fiendwar source area requires combat":从还没清掉敌人的区域走出去
|
||||
// 一律拒绝。客户端自己不认这条规则(它照走不误),于是 2026-10-01 真机出现
|
||||
// "客户端的角色已经在 B、服务端 MemberAreas 还记着 A" 的分叉,直接导致
|
||||
// 「同区域不一起进图」,以及之后他再按进图被同一条拒绝挡下时的**卡死**。
|
||||
// 地图推进由 [AREA INFO] 的相邻关系与 BOSS 门禁把关,不需要这条自造规则。
|
||||
func TestFiendWarMoveAllowsLeavingAnUnclearedArea(t *testing.T) {
|
||||
b := &FiendWarBattle{
|
||||
Config: &FiendWarBattleConfig{WaitingArea: 2, Areas: map[int]FiendWarArea{
|
||||
2: {Index: 2, Next: []int{6}, MemberMax: 8},
|
||||
6: {Index: 6, Dungeon: 4954, Next: []int{2, 10}, MemberMax: 4},
|
||||
10: {Index: 10, Dungeon: 4958, Next: []int{6}, MemberMax: 4},
|
||||
}},
|
||||
// 区域 6 里还站着一只没打掉的守军 —— 这正是被拒的那种输入。
|
||||
Areas: map[int]FiendWarAreaState{6: {Enemy: FiendWarUnit{ID: 11, Type: 1}}},
|
||||
GroupAreas: map[byte]int{1: 6},
|
||||
MemberAreas: map[uint16]int{1: 6},
|
||||
}
|
||||
if !b.AreaOpen(6) {
|
||||
t.Fatal("fixture 没造出'区域里还有敌人'这个输入")
|
||||
}
|
||||
if err := b.MoveMember(1, 1, 10, time.Now()); err != nil {
|
||||
t.Fatalf("站在没打完的区域里走不出去:%v", err)
|
||||
}
|
||||
if b.MemberArea(1, 1) != 10 {
|
||||
t.Fatalf("走位没有被记进战场:area=%d want 10", b.MemberArea(1, 1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiendWarMonstersSpawnAwayFromEnemyArmies(t *testing.T) {
|
||||
b := &FiendWarBattle{
|
||||
Config: &FiendWarBattleConfig{Areas: map[int]FiendWarArea{
|
||||
14: {Index: 14, Dungeon: 4962}, 16: {Index: 16, Dungeon: 4964}, 18: {Index: 18, Dungeon: 4966},
|
||||
3: {Index: 3, Dungeon: 4951}, 5: {Index: 5, Dungeon: 4953},
|
||||
}},
|
||||
Areas: map[int]FiendWarAreaState{
|
||||
14: {Enemy: FiendWarUnit{ID: 1, Type: 1}}, 16: {Enemy: FiendWarUnit{ID: 2, Type: 2}},
|
||||
18: {Enemy: FiendWarUnit{ID: 3, Type: 3}}, 3: {}, 5: {},
|
||||
},
|
||||
Symbols: map[uint32]int{}, NextUnitID: 3,
|
||||
}
|
||||
b.act(FiendWarInstruction{Op: "create monsters"}, time.Unix(1700000000, 0))
|
||||
for _, id := range []int{14, 16, 18} {
|
||||
if b.Areas[id].Monster.ID != 0 {
|
||||
t.Fatalf("spawn created an immediate enemy/monster battle in area %d", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []int{3, 5} {
|
||||
if b.Areas[id].Monster.ID == 0 {
|
||||
t.Fatalf("empty area %d was not populated", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFFiendWarBattle(t *testing.T) {
|
||||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||||
if path == "" {
|
||||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||||
}
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadFiendWarConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewFiendWarBattle(c.Battle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
|
||||
if err = b.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Battle.Areas) != 30 || len(c.Battle.Raid.Phases) != 1 || b.Value("fiendwar init") != 1 {
|
||||
t.Fatalf("initial battle: %+v", b)
|
||||
}
|
||||
for _, id := range []int{14, 16, 18} {
|
||||
if b.Areas[id].Enemy.ID == 0 {
|
||||
t.Fatalf("missing initial enemy %d", id)
|
||||
}
|
||||
}
|
||||
if err = b.Tick(now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Value("alive monster count") != 3 {
|
||||
t.Fatal("monster population", b.Value("alive monster count"))
|
||||
}
|
||||
if err = b.Move(1, 30); err == nil {
|
||||
t.Fatal("accepted boss teleport")
|
||||
}
|
||||
// Isolate the real clearing/restore triggers from unrelated moving armies.
|
||||
for _, id := range []int{27, 28, 29} {
|
||||
a := c.Battle.Areas[id]
|
||||
b.GroupAreas[1] = id
|
||||
if err = b.Enter(1, a.Dungeon, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = b.Clear(a.Dungeon, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !b.AreaOpen(30) || !b.AreaOpen(31) {
|
||||
t.Fatal("three center clears did not open both bosses")
|
||||
}
|
||||
if b.Timers[FiendWarTimerKey{27, "top_restore_center"}].Deadline != now.Add(720*time.Second) {
|
||||
t.Fatal("PVF center restore timer missing")
|
||||
}
|
||||
for i, id := range []int{30, 31} {
|
||||
a := c.Battle.Areas[id]
|
||||
b.GroupAreas[byte(i+1)] = id
|
||||
if err = b.Enter(byte(i+1), a.Dungeon, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = b.Clear(4978, now); err == nil {
|
||||
t.Fatal("accepted boss clear before mechanics")
|
||||
}
|
||||
for _, id := range []int{306000463, 306000460} {
|
||||
if err = b.Apply(FiendWarEvent{Kind: "on enter map", ID: id}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if b.Value("boss pattern state") != 1111 {
|
||||
t.Fatal("boss mechanisms not initialized")
|
||||
}
|
||||
// 四颗球是**两队共享的池子**:每一侧各拿一颗不同的球,谁打掉自己那颗,
|
||||
// 那颗球就从机制条上永久消失、该侧再从剩下的球里补一颗;
|
||||
// 四颗都清掉 → 两侧都到 9(本体破防)。
|
||||
for round := 0; round < 2; round++ {
|
||||
for _, room := range []struct {
|
||||
dungeon int
|
||||
side string
|
||||
}{{4978, "top boss"}, {4953, "bot boss"}} {
|
||||
state := b.Value(room.side + " state")
|
||||
if state < 5 || state > 8 {
|
||||
t.Fatalf("round %d: %s has no ball to kill (state=%d)", round, room.side, state)
|
||||
}
|
||||
otherSide := "bot boss"
|
||||
if room.side == "bot boss" {
|
||||
otherSide = "top boss"
|
||||
}
|
||||
otherBefore := b.Value(otherSide + " state")
|
||||
patternBefore := b.Value("boss pattern state")
|
||||
if err = b.BossReport(room.dungeon, 1, state, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != patternBefore-fiendWarCloneWeights[state-5] {
|
||||
t.Fatalf("%s kind=1 value=%d cleared the wrong ball: pattern %d → %d",
|
||||
room.side, state, patternBefore, got)
|
||||
}
|
||||
if got := b.Value(otherSide + " state"); got != otherBefore {
|
||||
t.Fatalf("%s 打掉自己那颗球,却动了对面:%s state %d → %d",
|
||||
room.side, otherSide, otherBefore, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.Value("boss pattern state") != 0 {
|
||||
t.Fatalf("四颗球清完后机制条 = %d,want 0", b.Value("boss pattern state"))
|
||||
}
|
||||
if b.Value("top boss state") != 9 || b.Value("bot boss state") != 9 {
|
||||
t.Fatalf("四颗球清完后两侧应当都是 9(本体破防),got top=%d bot=%d",
|
||||
b.Value("top boss state"), b.Value("bot boss state"))
|
||||
}
|
||||
for _, id := range []int{4978, 4953} {
|
||||
if err = b.BossReport(id, 0, 0, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = b.Clear(4978, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Complete {
|
||||
t.Fatal("one boss granted victory")
|
||||
}
|
||||
if err = b.Clear(4953, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !b.Complete {
|
||||
t.Fatal("both bosses did not finish raid")
|
||||
}
|
||||
clone := b.Clone()
|
||||
clone.Symbols[92] = 0
|
||||
if b.Symbols[92] != 10 {
|
||||
t.Fatal("snapshot aliases live state")
|
||||
}
|
||||
failed, _ := NewFiendWarBattle(c.Battle)
|
||||
if err = failed.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 30 && !failed.Failed; i++ {
|
||||
if err = failed.Tick(now.Add(time.Duration(i) * 150 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !failed.Failed {
|
||||
t.Fatal("enemy arrival did not fail defense")
|
||||
}
|
||||
t.Run("boss retreat and retry", func(t *testing.T) {
|
||||
b, _ := NewFiendWarBattle(c.Battle)
|
||||
if err := b.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"top command center state", "mid command center state", "bot command center state"} {
|
||||
b.set(name, 0)
|
||||
}
|
||||
if err := b.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.GroupAreas[1], b.GroupAreas[2] = 30, 31
|
||||
for i, dungeon := range []int{4978, 4953} {
|
||||
if err := b.Enter(byte(i+1), dungeon, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := b.Tick(now.Add(600 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Areas[30].Group != 0 || b.Areas[31].Group != 0 || len(b.Retreats) != 2 {
|
||||
t.Fatal("timeout did not release both boss rooms")
|
||||
}
|
||||
if b.Value("top boss state") != 1 || b.Value("bot boss state") != 1 {
|
||||
t.Fatal("boss rooms did not reopen")
|
||||
}
|
||||
if b.GroupAreas[1] != 30 || b.GroupAreas[2] != 31 {
|
||||
t.Fatal("retreat diverged from frozen town origins")
|
||||
}
|
||||
if err := b.Enter(1, 4978, now.Add(601*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Value("kick out character") != 0 {
|
||||
t.Fatal("retry retained old kick request")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// The live run of 2026-10-01 (packet_log-20261001-007, raid 12058625) killed both
|
||||
// 罗什 rooms and got nothing: `game-raid-dungeon-completion-publish-failed
|
||||
// error=fiendwar boss mechanisms unfinished`. The cause is on the wire, not in the
|
||||
// PVF -- across that whole session the client sent 34 op1511 packets and every one
|
||||
// of them was `kind=0 value=100` (a boss-HP heartbeat); it never sent the `kind=1`
|
||||
// mechanism report the PVF state machine needs to walk 5..9. So the clear has to be
|
||||
// driven by the authoritative boss death, which is what these tests pin.
|
||||
func TestFiendWarClientBossClearSurvivesAMissingMechanismHandshake(t *testing.T) {
|
||||
b := newRealFiendWarBossBattle(t)
|
||||
// The two mechanism maps the client DID walk into (04:53:47 / 04:54:25 live), so
|
||||
// this is the exact state the live run was in when the bosses died.
|
||||
enterFiendWarMechanismMaps(t, b)
|
||||
|
||||
// 两队各拿一颗**不同**的球(5=宝石/虚无 6=火 7=冰 8=风),球池仍是满的。
|
||||
for _, side := range []string{"top boss", "bot boss"} {
|
||||
if got := b.Value(side + " state"); got < 5 || got > 8 {
|
||||
t.Fatalf("%s state after boss pattern init = %d, want 5..8", side, got)
|
||||
}
|
||||
}
|
||||
if b.Value("top boss state") == b.Value("bot boss state") {
|
||||
t.Fatalf("两侧拿到了同一颗球:top=%d bot=%d", b.Value("top boss state"), b.Value("bot boss state"))
|
||||
}
|
||||
// The live packet, verbatim: `01 e7 05 … 00000000 64000000` = kind 0 value 100.
|
||||
// It used to be rejected ("invalid fiendwar boss HP") because the state was not
|
||||
// yet in 5..9; the client's own HUD value must never be refused.
|
||||
if err := b.BossReport(4978, 0, 100, time.Unix(0, 0)); err != nil {
|
||||
t.Fatalf("boss HP report rejected at state 5: %v", err)
|
||||
}
|
||||
|
||||
if err := b.ClearClientBossRoom(4978, time.Unix(0, 0)); err != nil {
|
||||
t.Fatalf("client boss clear refused: %v", err)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 10 {
|
||||
t.Fatalf("top boss state after its clear = %d, want 10", got)
|
||||
}
|
||||
if b.Complete {
|
||||
t.Fatal("one boss finished the raid")
|
||||
}
|
||||
// The two bosses die in either order now: 黑暗超时空 at 04:54:31, 纯白超时空
|
||||
// 20 s later. The first clear must not have needed the other side's HP.
|
||||
if err := b.ClearClientBossRoom(4953, time.Unix(0, 0)); err != nil {
|
||||
t.Fatalf("second client boss clear refused: %v", err)
|
||||
}
|
||||
if !b.Complete {
|
||||
t.Fatal("both boss rooms cleared but the phase never completed")
|
||||
}
|
||||
}
|
||||
|
||||
// Even without the mechanism maps at all (state 2, the shape a client that never
|
||||
// leaves the boss room produces) the clear has to land, because the dungeon's own
|
||||
// op39/op115/op117 chain already proved the actor death.
|
||||
func TestFiendWarClientBossClearWorksFromTheEntryState(t *testing.T) {
|
||||
b := newRealFiendWarBossBattle(t)
|
||||
if got := b.Value("top boss state"); got != 2 {
|
||||
t.Fatalf("top boss state on entry = %d, want 2", got)
|
||||
}
|
||||
if err := b.BossReport(4978, 0, 100, time.Unix(0, 0)); err != nil {
|
||||
t.Fatalf("boss HP report rejected at state 2: %v", err)
|
||||
}
|
||||
if err := b.Clear(4978, time.Unix(0, 0)); err == nil {
|
||||
t.Fatal("the strict Clear accepted a boss room whose mechanisms never ran")
|
||||
}
|
||||
if err := b.ClearClientBossRoom(4978, time.Unix(0, 0)); err != nil {
|
||||
t.Fatalf("client boss clear refused: %v", err)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 10 {
|
||||
t.Fatalf("top boss state after its clear = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A mechanism report the client really does send (kind 1, value == state) keeps
|
||||
// working exactly as before, and a clear that follows it must not re-derive the
|
||||
// state.
|
||||
func TestFiendWarClientBossClearLeavesADrivenMechanismAlone(t *testing.T) {
|
||||
b := newRealFiendWarBossBattle(t)
|
||||
enterFiendWarMechanismMaps(t, b)
|
||||
now := time.Unix(0, 0)
|
||||
// 每一侧打掉**自己手上那颗**(value == 自己这一侧的 boss state),四颗清完。
|
||||
for _, room := range []struct {
|
||||
dungeon int
|
||||
side string
|
||||
}{{4978, "top boss"}, {4953, "bot boss"}, {4978, "top boss"}, {4953, "bot boss"}} {
|
||||
state := b.Value(room.side + " state")
|
||||
if state < 5 || state > 8 {
|
||||
t.Fatalf("%s 没有球可打:state=%d", room.side, state)
|
||||
}
|
||||
if err := b.BossReport(room.dungeon, 1, state, now); err != nil {
|
||||
t.Fatalf("mechanism %s state %d: %v", room.side, state, err)
|
||||
}
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 0 {
|
||||
t.Fatalf("四颗球清完后机制条 = %d,want 0", got)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 9 {
|
||||
t.Fatalf("mechanism sequence left top boss state at %d, want 9", got)
|
||||
}
|
||||
if err := b.BossReport(4978, 0, 0, now); err != nil {
|
||||
t.Fatalf("damage-window HP report: %v", err)
|
||||
}
|
||||
if err := b.ClearClientBossRoom(4978, now); err != nil {
|
||||
t.Fatalf("client boss clear refused: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 0 {
|
||||
t.Fatalf("a driven clear overwrote the live mechanism symbols: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-side HP guard: one boss at zero HP no longer blocks the other side's
|
||||
// clear, and the strict Clear still refuses the side whose mechanism is unfinished.
|
||||
func TestFiendWarBossClearIsDecidedPerSide(t *testing.T) {
|
||||
now := time.Unix(0, 0)
|
||||
config := &FiendWarBattleConfig{
|
||||
Areas: map[int]FiendWarArea{
|
||||
30: {Index: 30, Dungeon: 4978, Kind: "top boss"},
|
||||
31: {Index: 31, Dungeon: 4953, Kind: "bot boss"},
|
||||
},
|
||||
Symbols: map[string]uint32{
|
||||
"top boss state": 1, "bot boss state": 2,
|
||||
"top boss hp": 3, "bot boss hp": 4,
|
||||
"top boss view hp": 5, "bot boss view hp": 6,
|
||||
"boss pattern state": 7,
|
||||
},
|
||||
}
|
||||
b := &FiendWarBattle{
|
||||
Config: config,
|
||||
Symbols: map[uint32]int{1: 9, 2: 5, 3: 0, 4: 100, 5: 0, 6: 100},
|
||||
Areas: map[int]FiendWarAreaState{30: {Group: 1}, 31: {Group: 2}},
|
||||
DungeonStates: map[int]string{},
|
||||
Timers: map[FiendWarTimerKey]FiendWarTimer{},
|
||||
GroupAreas: map[byte]int{},
|
||||
MemberAreas: map[uint16]int{},
|
||||
}
|
||||
if err := b.Clear(4978, now); err != nil {
|
||||
t.Fatalf("top boss clear blocked by the other side's HP: %v", err)
|
||||
}
|
||||
if err := b.Clear(4953, now); err == nil {
|
||||
t.Fatal("bot boss clear accepted while its mechanism was unfinished")
|
||||
}
|
||||
if err := b.ClearClientBossRoom(4953, now); err != nil {
|
||||
t.Fatalf("client boss clear refused on the unfinished side: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRealFiendWarBossBattle(t *testing.T) *FiendWarBattle {
|
||||
t.Helper()
|
||||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||||
if path == "" {
|
||||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||||
}
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadFiendWarConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewFiendWarBattle(c.Battle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(0, 0)
|
||||
if err := b.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Clearing the three 兵站本营 by hand is a ten-dungeon walk; the objective is
|
||||
// only the state it leaves behind ([IF … COMMAND CENTER STATE] 0 → both boss
|
||||
// rooms open), so drive the symbols the same way the retreat subtest does.
|
||||
for _, name := range []string{"top command center state", "mid command center state", "bot command center state"} {
|
||||
b.set(name, 0)
|
||||
}
|
||||
if err := b.Apply(FiendWarEvent{}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.GroupAreas[1], b.GroupAreas[2] = 30, 31
|
||||
for i, dungeon := range []int{4978, 4953} {
|
||||
if err := b.Enter(byte(i+1), dungeon, now); err != nil {
|
||||
t.Fatalf("enter boss room %d: %v", dungeon, err)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func enterFiendWarMechanismMaps(t *testing.T, b *FiendWarBattle) {
|
||||
t.Helper()
|
||||
now := time.Unix(0, 0)
|
||||
for _, mapID := range []int{306000463, 306000460} {
|
||||
if err := b.Apply(FiendWarEvent{Kind: "on enter map", ID: mapID}, now); err != nil {
|
||||
t.Fatalf("on enter map %d: %v", mapID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const FiendWarDailyScope = "raid:fiendwar:daily_entries"
|
||||
|
||||
func FiendWarDailyUsed(record repository.SettingsRecord, account string, now time.Time) (int, error) {
|
||||
if record.Values["day"] != strconv.FormatInt(premium.ServiceDayID(now), 10) {
|
||||
return 0, nil
|
||||
}
|
||||
value := record.Values["account:"+account]
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
used, err := strconv.Atoi(value)
|
||||
if err != nil || used < 0 {
|
||||
return 0, fmt.Errorf("invalid fiendwar daily entry record")
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// Called inside the same roster inventory/settings transaction as entry tickets.
|
||||
func ReserveFiendWarDailyEntries(record repository.SettingsRecord, accounts []string, limit int, now time.Time) (repository.SettingsRecord, error) {
|
||||
if limit <= 0 || len(accounts) == 0 {
|
||||
return record, fmt.Errorf("fiendwar daily entry configuration missing")
|
||||
}
|
||||
next := repository.CloneSettings(record)
|
||||
day := strconv.FormatInt(premium.ServiceDayID(now), 10)
|
||||
if next.Values["day"] != day {
|
||||
next.Values = map[string]string{"day": day}
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account == "" {
|
||||
return record, fmt.Errorf("fiendwar daily entry account missing")
|
||||
}
|
||||
used, err := FiendWarDailyUsed(next, account, now)
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
if used >= limit {
|
||||
return record, fmt.Errorf("fiendwar daily account entry limit reached")
|
||||
}
|
||||
next.Values["account:"+account] = strconv.Itoa(used + 1)
|
||||
}
|
||||
next.Scope, next.UpdatedAt = FiendWarDailyScope, now
|
||||
return next, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestFiendWarDailyEntryBoundsResetAndIsolation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
r, err := ReserveFiendWarDailyEntries(repository.SettingsRecord{}, []string{"a", "a", "b"}, 2, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := repository.CloneSettings(r)
|
||||
if _, err := ReserveFiendWarDailyEntries(r, []string{"b", "a"}, 2, now); err == nil {
|
||||
t.Fatal("over limit accepted")
|
||||
}
|
||||
if !reflect.DeepEqual(before, r) {
|
||||
t.Fatal("rejected group consumed an earlier member's entry")
|
||||
}
|
||||
if got, _ := FiendWarDailyUsed(r, "a", now); got != 2 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got, _ := FiendWarDailyUsed(r, "b", now); got != 1 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got, _ := FiendWarDailyUsed(r, "a", now.Add(24*time.Hour)); got != 0 {
|
||||
t.Fatal("service day did not reset")
|
||||
}
|
||||
if _, err := ReserveFiendWarDailyEntries(r, []string{"a"}, 2, now.Add(24*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package raid
|
||||
|
||||
// 超时空之战:战况表格子上要画的"怪物头像"用的是**真实的怪物 ID**,而这只怪是
|
||||
// 谁,写在 PVF 里,不是服务端自编的一张表:
|
||||
//
|
||||
// dungeon/gcontents/180621_fiendwar/cosmo_*.dgn
|
||||
// [maze info] → [map specification] `boss` x y <mapID>
|
||||
// map/gcontents/180621_fiendwar/dungeon/(2,0)_Boss_<mapID>.map
|
||||
// [monster] <怪物ID> ... `[fixed]` `[boss]`
|
||||
//
|
||||
// 本文件把这条对应关系固化成表;`raid_fiendwar_monster_pvf_test.go` 会逐条回读
|
||||
// 真 PVF 复核(每张 maze 的 BOSS 房地图、里面那只 [boss]、以及 maze 张数),
|
||||
// 数据漂移会让测试先红,而不是等玩家看见错的头像。
|
||||
//
|
||||
// 权威出处(百度百科「超时空之战」· 怪物信息):
|
||||
// "其中前哨基地/兵站本营/攻击基地中的怪物以随机分配的形式出现在地图中。"
|
||||
// —— 分配发生在**建图那一刻**,所以前哨基地/兵站本营的领主是开团时就抽定的
|
||||
// (见 FiendWarBattle.FixtureMazes,按格子位置钉死),雷达通关后战况表显示的与进图实际打到的才会
|
||||
// 是同一只。
|
||||
//
|
||||
// 权威出处(同一页 · 地图构造):"雷达:该副本通关后增加所有玩家10%技能伤害,
|
||||
// 并显示地图中怪物头像。(反之怪物头像显示问号)" —— 揭示的开关就是 RADAR STATE。
|
||||
|
||||
// fiendWarArmyBossIDs 是"攻击基地用"的四只稀有怪,也就是敌军军团 type 1..4 会走进
|
||||
// 的那张 maze(maze = type-1)的领主:
|
||||
//
|
||||
// 1 魅妖公主(110979) / 2 融合型恶魔(110977) / 3 狂热恶魔:破坏者(110987) / 4 超合金恶魔:艾罗伊(110982)
|
||||
//
|
||||
// 千分比权重就是 PVF 的 [ENEMY CREATE RATE] 1 30000 2 30000 3 30000 4 10000。
|
||||
var fiendWarArmyBossIDs = [4]uint32{110979, 110977, 110987, 110982}
|
||||
|
||||
// fiendWarOrcBossIDs 是"随机中立"的原生怪(非移动型)领主,也就是 monster type
|
||||
// 1..2 会走进的那张 maze(maze = type+3)的领主:
|
||||
//
|
||||
// 1 面霸1号(110714) / 2 兽人队长:血之埃克斯(110975)
|
||||
//
|
||||
// maze 4/5 的小怪是山贼兽人/山贼弩兵/山贼头头(FarmerOrc/ArcherOrc/BanditOrc/BanditOrcHead),
|
||||
// 与百科"原生怪物(非移动型怪物)· 随机中立"那一栏一致。
|
||||
var fiendWarOrcBossIDs = [2]uint32{110714, 110975}
|
||||
|
||||
// FiendWarFixtureBossIDs 是每个设施副本、逐张 maze 的领主。
|
||||
//
|
||||
// 长度就是这张副本的 maze 张数:雷达/炮塔/量子时空传送站只有 1 张(领主恒定),
|
||||
// 前哨基地与兵站本营各有 3 张(领主随机分配,开团抽定)。
|
||||
var FiendWarFixtureBossIDs = map[int][]uint32{
|
||||
// 雷达基地:磁力恶魔:马格内特
|
||||
4963: {110973},
|
||||
// 炮塔:常春魔藤
|
||||
4965: {110980},
|
||||
// 前哨基地:人类背叛者:宾柏 / 引力恶魔:薛丁恶 / 宇宙粉碎机:克鲁瑟
|
||||
4967: {110981, 110984, 110708},
|
||||
4969: {110981, 110984, 110708},
|
||||
4971: {110981, 110984, 110708},
|
||||
// 兵站本营:光之恶魔:欧格里 / 暗之恶魔:艾森 / 重力恶魔:白斯
|
||||
4975: {110709, 110978, 110992},
|
||||
4976: {110709, 110978, 110992},
|
||||
4977: {110709, 110978, 110992},
|
||||
// 量子时空传送站:宇宙恶魔:罗什(纯白 / 黑暗)
|
||||
4953: {110995},
|
||||
4978: {110994},
|
||||
}
|
||||
|
||||
// FiendWarArmyBossID 返回敌军军团某兵种对应的领主。
|
||||
func FiendWarArmyBossID(enemyType byte) (uint32, bool) {
|
||||
if enemyType < 1 || int(enemyType) > len(fiendWarArmyBossIDs) {
|
||||
return 0, false
|
||||
}
|
||||
return fiendWarArmyBossIDs[enemyType-1], true
|
||||
}
|
||||
|
||||
// FiendWarOrcBossID 返回原生怪(中立兽人)某兵种对应的领主。
|
||||
func FiendWarOrcBossID(monsterType byte) (uint32, bool) {
|
||||
if monsterType < 1 || int(monsterType) > len(fiendWarOrcBossIDs) {
|
||||
return 0, false
|
||||
}
|
||||
return fiendWarOrcBossIDs[monsterType-1], true
|
||||
}
|
||||
|
||||
// FiendWarFixtureBossID 返回设施副本第 maze 张图的领主。
|
||||
func FiendWarFixtureBossID(dungeon, maze int) (uint32, bool) {
|
||||
bosses, ok := FiendWarFixtureBossIDs[dungeon]
|
||||
if !ok || maze < 0 || maze >= len(bosses) {
|
||||
return 0, false
|
||||
}
|
||||
return bosses[maze], true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package raid
|
||||
|
||||
import "testing"
|
||||
|
||||
// 前哨基地/兵站本营的领主是**固定的**(操作员 2026-10-01 复核历史截图后的口径):
|
||||
// 战况表上每个格子画的就是那一只,服务端必须让实际出现的是同一只 —— 所以这里不抽签,
|
||||
// 而是按格子位置钉死:上 → maze 0、中 → 1、下 → 2。
|
||||
//
|
||||
// 只有 maze 张数 > 1 的设施区域才会登记;雷达/炮塔/传送站只有一张图,不登记。
|
||||
func TestFiendWarFixtureMazesAreFixedByCellPosition(t *testing.T) {
|
||||
config := &FiendWarBattleConfig{
|
||||
Raid: &AntonConfig{PhaseMax: 1, MemberMax: 8},
|
||||
Rules: []FiendWarRule{{}},
|
||||
Areas: map[int]FiendWarArea{
|
||||
6: {Index: 6, Dungeon: 4954}, // 普通作战区域:由兵种定图
|
||||
15: {Index: 15, Dungeon: 4963, Kind: "radar"}, // 单图
|
||||
17: {Index: 17, Dungeon: 4965, Kind: "battery"}, // 单图
|
||||
19: {Index: 19, Dungeon: 4967, Kind: "top outpost"}, // 上哨 → maze 0
|
||||
21: {Index: 21, Dungeon: 4969, Kind: "mid outpost"}, // 中哨 → maze 1
|
||||
23: {Index: 23, Dungeon: 4971, Kind: "bot outpost"}, // 下哨 → maze 2
|
||||
27: {Index: 27, Dungeon: 4975, Kind: "top command center"},
|
||||
28: {Index: 28, Dungeon: 4976, Kind: "mid command center"},
|
||||
29: {Index: 29, Dungeon: 4977, Kind: "bot command center"},
|
||||
30: {Index: 30, Dungeon: 4978, Kind: "top boss"}, // 单图
|
||||
},
|
||||
}
|
||||
b, err := NewFiendWarBattle(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := b.MazeForArea(6); ok {
|
||||
t.Fatal("普通作战区域不该登记固定图:它的图由区域里那只兵种决定")
|
||||
}
|
||||
for _, id := range []int{15, 17, 30} {
|
||||
if _, ok := b.MazeForArea(id); ok {
|
||||
t.Fatalf("单图设施 %d 不该登记(恒为 0)", id)
|
||||
}
|
||||
}
|
||||
for id, want := range map[int]int{19: 0, 21: 1, 23: 2, 27: 0, 28: 1, 29: 2} {
|
||||
maze, ok := b.MazeForArea(id)
|
||||
if !ok {
|
||||
t.Fatalf("多图设施 %d 没有登记固定图", id)
|
||||
}
|
||||
if maze != want {
|
||||
t.Fatalf("设施 %d 的固定图 = %d,want %d(上/中/下 对应 0/1/2)", id, maze, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 固定就是把随机性去掉:建一百次战场,同一格的图必须**一模一样**。
|
||||
for range 100 {
|
||||
fresh, err := NewFiendWarBattle(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, want := range map[int]int{19: 0, 21: 1, 23: 2, 27: 0, 28: 1, 29: 2} {
|
||||
if maze, _ := fresh.MazeForArea(id); maze != want {
|
||||
t.Fatalf("重建战场后设施 %d 的图变成了 %d,want %d —— 说明还在抽签", id, maze, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 快照必须带上这张固定图:战况表是从 Clone 出来的 state 上发的。
|
||||
clone := b.Clone()
|
||||
original, _ := b.MazeForArea(19)
|
||||
cloned, ok := clone.MazeForArea(19)
|
||||
if !ok || cloned != original {
|
||||
t.Fatalf("Clone 丢了固定图:%d -> %d(ok=%t)", original, cloned, ok)
|
||||
}
|
||||
clone.FixtureMazes[19] = (original + 1) % 3
|
||||
if again, _ := b.MazeForArea(19); again != original {
|
||||
t.Fatal("Clone 改固定图影响到了原战场")
|
||||
}
|
||||
}
|
||||
|
||||
// 表本身的自洽性:四只军团领主、两只原生怪领主,互不相同且非 0;
|
||||
// 每个设施副本的 maze 张数就是它在 PVF 里的张数(1 或 3)。
|
||||
func TestFiendWarMonsterTablesAreWellFormed(t *testing.T) {
|
||||
if len(fiendWarArmyBossIDs) != 4 {
|
||||
t.Fatalf("军团领主应有 4 只([ENEMY CREATE RATE] 1..4),实测 %d", len(fiendWarArmyBossIDs))
|
||||
}
|
||||
if len(fiendWarOrcBossIDs) != 2 {
|
||||
t.Fatalf("原生怪领主应有 2 只(maze 4/5),实测 %d", len(fiendWarOrcBossIDs))
|
||||
}
|
||||
seen := map[uint32]string{}
|
||||
for i, id := range fiendWarArmyBossIDs {
|
||||
if id == 0 {
|
||||
t.Fatalf("军团领主 %d 为空", i+1)
|
||||
}
|
||||
seen[id] = "army"
|
||||
}
|
||||
for i, id := range fiendWarOrcBossIDs {
|
||||
if id == 0 {
|
||||
t.Fatalf("原生怪领主 %d 为空", i+1)
|
||||
}
|
||||
seen[id] = "orc"
|
||||
}
|
||||
if len(seen) != 6 {
|
||||
t.Fatalf("六只领主里有重复:%v", seen)
|
||||
}
|
||||
wantMazes := map[int]int{4963: 1, 4965: 1, 4967: 3, 4969: 3, 4971: 3, 4975: 3, 4976: 3, 4977: 3, 4953: 1, 4978: 1}
|
||||
if len(FiendWarFixtureBossIDs) != len(wantMazes) {
|
||||
t.Fatalf("设施副本应有 %d 个,实测 %d", len(wantMazes), len(FiendWarFixtureBossIDs))
|
||||
}
|
||||
for dungeon, want := range wantMazes {
|
||||
bosses, ok := FiendWarFixtureBossIDs[dungeon]
|
||||
if !ok {
|
||||
t.Fatalf("缺副本 %d", dungeon)
|
||||
}
|
||||
if len(bosses) != want {
|
||||
t.Fatalf("副本 %d 的 maze 张数 = %d,want %d", dungeon, len(bosses), want)
|
||||
}
|
||||
for maze, id := range bosses {
|
||||
if id == 0 {
|
||||
t.Fatalf("副本 %d maze %d 的领主为空", dungeon, maze)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 三个前哨、三个兵站本营共用同一组领主(同一组 .map 的三张 maze)。
|
||||
for _, pair := range [][2]int{{4967, 4969}, {4969, 4971}, {4975, 4976}, {4976, 4977}} {
|
||||
left, right := FiendWarFixtureBossIDs[pair[0]], FiendWarFixtureBossIDs[pair[1]]
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
t.Fatalf("副本 %d/%d 的 maze %d 领主不一致:%d vs %d", pair[0], pair[1], i, left[i], right[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const FiendWarPVFPath = "etc/raid/fiendwar/fiendwar.etc"
|
||||
|
||||
// FiendWarConfig keeps the area graph separate from the shared ticket/reward grammar.
|
||||
type FiendWarConfig struct {
|
||||
MemberMax int
|
||||
StartMinimumMembers int
|
||||
EntryCost AntonEntryCost
|
||||
// These values live in the global section of fiendwar.etc and are used by
|
||||
// the battle owner when a run is interrupted or failed.
|
||||
DisconnectMemberWaitSeconds int
|
||||
Retry bool
|
||||
RetryItem AntonEntryCost
|
||||
// Ranking rows belong to PHASE / CONDITION FOR RANK, not the globals.
|
||||
DeadCount []FiendWarDeadCountRule
|
||||
Battle *FiendWarBattleConfig
|
||||
}
|
||||
|
||||
// FiendWarDeadCountRule maps an inclusive death-count range to the native
|
||||
// result tier. The PVF uses four rows: min, max, tier.
|
||||
type FiendWarDeadCountRule struct {
|
||||
Min, Max, Tier int
|
||||
}
|
||||
|
||||
// FiendWarDeadCountTier preserves the inclusive ranges from the PVF. Counts
|
||||
// outside the configured rows have no proven native tier.
|
||||
func FiendWarDeadCountTier(config *FiendWarConfig, count int) (int, bool) {
|
||||
if config != nil {
|
||||
for _, rule := range config.DeadCount {
|
||||
if count >= rule.Min && count <= rule.Max {
|
||||
return rule.Tier, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func LoadFiendWarConfig(source dnfpvf.Source) (*FiendWarConfig, error) {
|
||||
if source == nil {
|
||||
return nil, fmt.Errorf("fiendwar raid PVF source is required")
|
||||
}
|
||||
text, err := source.ReadText(FiendWarPVFPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document, err := dnfpvf.Parse(FiendWarPVFPath, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := ParseFiendWarConfig(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
symbolText, err := source.ReadText(FiendWarPVFPath + ".symbol")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
symbols, err := dnfpvf.Parse(FiendWarPVFPath+".symbol", symbolText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Battle, err = parseFiendWarBattle(config, document, symbols)
|
||||
return config, err
|
||||
}
|
||||
|
||||
func ParseFiendWarConfig(document *dnfpvf.Document) (*FiendWarConfig, error) {
|
||||
if document == nil || antonPathKey(document.Path) != antonPathKey(FiendWarPVFPath) {
|
||||
return nil, fmt.Errorf("invalid fiendwar raid PVF document")
|
||||
}
|
||||
phases, err := antonFindBlocks(document, "phase", 0, len(document.Sections))
|
||||
if err != nil || len(phases) != 1 {
|
||||
return nil, fmt.Errorf("invalid fiendwar phase boundary: %v", err)
|
||||
}
|
||||
end := phases[0].open
|
||||
maximum, err := antonReadPositiveIntSection(document, "fiendwar member max", 0, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
minimum, err := antonReadPositiveIntSection(document, "fiendwar start minimum member", 0, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maximum > MaxAttackPartyMembers || minimum > maximum {
|
||||
return nil, fmt.Errorf("invalid fiendwar roster bounds: %d..%d", minimum, maximum)
|
||||
}
|
||||
cost, err := antonParseEntryCost(document, 0, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wait, err := antonReadPositiveIntSection(document, "disconn member wait time", 0, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
retryValues, err := fiendWarGlobalInts(document, "retry", 0, end)
|
||||
if err != nil || len(retryValues) != 1 || (retryValues[0] != 0 && retryValues[0] != 1) {
|
||||
return nil, fmt.Errorf("invalid fiendwar retry: %v", err)
|
||||
}
|
||||
retryItem, err := antonParseEntryCostNamed(document, "retry item", 0, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranks, err := antonFindBlocks(document, "condition for rank", phases[0].open+1, phases[0].close)
|
||||
if err != nil || len(ranks) != 1 {
|
||||
return nil, fmt.Errorf("invalid fiendwar rank boundary: %v", err)
|
||||
}
|
||||
dead, err := parseFiendWarDeadCounts(document, ranks[0].open+1, ranks[0].close)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &FiendWarConfig{
|
||||
MemberMax: maximum, StartMinimumMembers: minimum, EntryCost: cost,
|
||||
DisconnectMemberWaitSeconds: wait, Retry: retryValues[0] == 1,
|
||||
RetryItem: retryItem, DeadCount: dead,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fiendWarGlobalInts(document *dnfpvf.Document, name string, start, end int) ([]int, error) {
|
||||
i, err := antonUniqueSection(document, name, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := antonSectionTokens(document, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([]int, len(tokens))
|
||||
for j, token := range tokens {
|
||||
values[j], err = antonIntToken(token, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func antonParseEntryCostNamed(document *dnfpvf.Document, name string, start, end int) (AntonEntryCost, error) {
|
||||
sectionIndex, found, err := antonOptionalUniqueSection(document, name, start, end)
|
||||
if err != nil {
|
||||
return AntonEntryCost{}, err
|
||||
}
|
||||
if !found {
|
||||
return AntonEntryCost{}, antonMissing(name, "required")
|
||||
}
|
||||
tokens, err := antonSectionTokens(document, sectionIndex)
|
||||
if err != nil {
|
||||
return AntonEntryCost{}, err
|
||||
}
|
||||
if len(tokens) != 2 {
|
||||
return AntonEntryCost{}, fmt.Errorf("%s: want two values", name)
|
||||
}
|
||||
item, err := antonPositiveUint32Token(tokens[0], name+" item id")
|
||||
if err != nil {
|
||||
return AntonEntryCost{}, err
|
||||
}
|
||||
count, err := antonPositiveUint32Token(tokens[1], name+" count")
|
||||
if err != nil {
|
||||
return AntonEntryCost{}, err
|
||||
}
|
||||
return AntonEntryCost{ItemID: item, Count: count}, nil
|
||||
}
|
||||
|
||||
func parseFiendWarDeadCounts(document *dnfpvf.Document, start, end int) ([]FiendWarDeadCountRule, error) {
|
||||
want := antonSectionName("dead count")
|
||||
sections := make([]int, 0, 4)
|
||||
for i := start; i < end; i++ {
|
||||
if antonSectionName(document.Sections[i].Name) == want {
|
||||
sections = append(sections, i)
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
return nil, fmt.Errorf("dead count: missing rows")
|
||||
}
|
||||
out := make([]FiendWarDeadCountRule, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
tokens, e := antonSectionTokens(document, section)
|
||||
if e != nil || len(tokens) != 3 {
|
||||
return nil, fmt.Errorf("dead count: want min/max/tier: %v", e)
|
||||
}
|
||||
values := [3]int{}
|
||||
for i, token := range tokens {
|
||||
values[i], e = antonIntToken(token, "dead count")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if values[0] < 0 || values[1] < values[0] || values[2] < 0 {
|
||||
return nil, fmt.Errorf("invalid dead count row %v", values)
|
||||
}
|
||||
out = append(out, FiendWarDeadCountRule{Min: values[0], Max: values[1], Tier: values[2]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
const fiendWarRecruitmentDocument = `[FIENDWAR MEMBER MAX]
|
||||
8
|
||||
[FIENDWAR START MINIMUM MEMBER]
|
||||
1
|
||||
[ENTRY COST]
|
||||
490007844 1
|
||||
[/ENTRY COST]
|
||||
[DISCONN MEMBER WAIT TIME]
|
||||
300
|
||||
[RETRY]
|
||||
1
|
||||
[RETRY ITEM]
|
||||
490007844 1
|
||||
[PHASE]
|
||||
[MEMBER MAX]
|
||||
4
|
||||
[CONDITION FOR RANK]
|
||||
[DEAD COUNT]
|
||||
0 1 0
|
||||
[DEAD COUNT]
|
||||
2 5 1
|
||||
[DEAD COUNT]
|
||||
6 9 2
|
||||
[DEAD COUNT]
|
||||
10 14 3
|
||||
[/CONDITION FOR RANK]
|
||||
[/PHASE]`
|
||||
|
||||
func TestFiendWarRecruitmentConfig(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, text string
|
||||
valid bool
|
||||
}{
|
||||
{"native globals", fiendWarRecruitmentDocument, true},
|
||||
{"missing maximum", strings.Replace(fiendWarRecruitmentDocument, "[FIENDWAR MEMBER MAX]", "[OTHER]", 1), false},
|
||||
{"minimum above capacity", strings.Replace(fiendWarRecruitmentDocument, "MINIMUM MEMBER]\n1", "MINIMUM MEMBER]\n9", 1), false},
|
||||
{"invalid ticket", strings.Replace(fiendWarRecruitmentDocument, "490007844 1", "0 1", 1), false},
|
||||
{"missing rank block", strings.ReplaceAll(fiendWarRecruitmentDocument, "CONDITION FOR RANK", "OTHER"), false},
|
||||
{"empty rank block", strings.ReplaceAll(fiendWarRecruitmentDocument, "DEAD COUNT", "OTHER"), false},
|
||||
{"invalid death range", strings.Replace(fiendWarRecruitmentDocument, "6 9 2", "9 6 2", 1), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
doc, err := dnfpvf.Parse(FiendWarPVFPath, tc.text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := ParseFiendWarConfig(doc)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("config=%+v err=%v", config, err)
|
||||
}
|
||||
if tc.valid && (config.MemberMax != 8 || config.StartMinimumMembers != 1 || config.EntryCost.ItemID != 490007844 || config.EntryCost.Count != 1 || config.DisconnectMemberWaitSeconds != 300 || !config.Retry || config.RetryItem.ItemID != 490007844 || !slices.Equal(config.DeadCount, []FiendWarDeadCountRule{{0, 1, 0}, {2, 5, 1}, {6, 9, 2}, {10, 14, 3}})) {
|
||||
t.Fatalf("config=%+v", config)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiendWarDeadCountTierUsesInclusivePVFRanges(t *testing.T) {
|
||||
config := &FiendWarConfig{DeadCount: []FiendWarDeadCountRule{{Min: 0, Max: 1, Tier: 0}, {Min: 2, Max: 5, Tier: 1}}}
|
||||
for count, want := range map[int]int{0: 0, 1: 0, 2: 1, 5: 1} {
|
||||
got, ok := FiendWarDeadCountTier(config, count)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("count=%d tier=%d ok=%t want=%d", count, got, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := FiendWarDeadCountTier(config, 6); ok {
|
||||
t.Fatal("out-of-range death count returned a tier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFFiendWarRecruitment(t *testing.T) {
|
||||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||||
if path == "" {
|
||||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||||
}
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := LoadFiendWarConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.MemberMax != 8 || config.StartMinimumMembers != 1 || config.EntryCost.ItemID != 490007844 || config.EntryCost.Count != 1 {
|
||||
t.Fatalf("actual PVF=%+v", config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 罗什的四颗属性球:**两队共享一个池子,两侧各拿一颗不同的球**。
|
||||
//
|
||||
// 官方攻略(3DM/COLG「超时空漩涡宇宙恶魔罗什攻略」)里的原话,就是下面这个流程:
|
||||
//
|
||||
// 「罗什四属性球出现后开始随机选择:黑暗队伍视角第一个分身为虚无、
|
||||
// 纯白队伍视角第一个分身为火 … 当黑暗队伍消灭虚无分身:罗什选择了风分身、
|
||||
// 纯白队伍视角虚无球消失(显示冰球/风球)… 当纯白队伍消灭火分身:罗什自动选择冰分身、
|
||||
// 黑暗队伍视角火球消失(显示冰球)… 当纯白队伍消灭冰分身 → 两队视角所有球消失本体出现」
|
||||
//
|
||||
// 也就是说"两侧都报同一个 value 才推进"那个**锁步**模型是错的(那是本树 2026-10-02
|
||||
// 之前的实现,操作员看到的"两颗球一直重复、机制顺序不对"就是它)。
|
||||
func TestFiendWarRoshanBallPoolIsSharedAndSidesAreIndependent(t *testing.T) {
|
||||
b, now := newFixtureBossBattle(t)
|
||||
// 钉住分球顺序,断言才能写死:宝石(5) → 火(6) → 冰(7) → 风(8)。
|
||||
b.PickClone = func(candidates []int) int { return 0 }
|
||||
|
||||
b.resetBossPattern()
|
||||
if got := b.Value("boss pattern state"); got != 1111 {
|
||||
t.Fatalf("球池 = %d,want 1111", got)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 5 {
|
||||
t.Fatalf("上半侧第一颗球 = %d,want 5(宝石/虚无)", got)
|
||||
}
|
||||
if got := b.Value("bot boss state"); got != 6 {
|
||||
t.Fatalf("下半侧第一颗球 = %d,want 6(火,不能与上半侧同一颗)", got)
|
||||
}
|
||||
|
||||
// 上半侧打掉自己那颗(虚无):只清 1000 位,并且**只给上半侧**补一颗新的(冰)。
|
||||
if err := b.BossReport(4978, 1, 5, now); err != nil {
|
||||
t.Fatalf("上半侧机制上报: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 111 {
|
||||
t.Fatalf("打掉虚无之后球池 = %d,want 111", got)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 7 {
|
||||
t.Fatalf("上半侧补到的球 = %d,want 7(冰)", got)
|
||||
}
|
||||
if got := b.Value("bot boss state"); got != 6 {
|
||||
t.Fatalf("上半侧打自己的球却动了对面:下半侧 = %d,want 6", got)
|
||||
}
|
||||
// 客户端把已经打掉的那颗再报一遍(重发/迟到)必须被拒:那颗球不在池子里了。
|
||||
if err := b.BossReport(4978, 1, 5, now); err == nil {
|
||||
t.Fatal("已经打掉的球又被接受了一次")
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 111 {
|
||||
t.Fatalf("重复上报动了球池:%d,want 111", got)
|
||||
}
|
||||
|
||||
// 下半侧打掉自己那颗(火):池子里只剩上半侧没拿的风。
|
||||
if err := b.BossReport(4953, 1, 6, now); err != nil {
|
||||
t.Fatalf("下半侧机制上报: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 11 {
|
||||
t.Fatalf("打掉火之后球池 = %d,want 11", got)
|
||||
}
|
||||
if got := b.Value("bot boss state"); got != 8 {
|
||||
t.Fatalf("下半侧补到的球 = %d,want 8(风)", got)
|
||||
}
|
||||
// 上半侧清掉冰:池子只剩下半侧手上的风 —— 上半侧没球可打,转 9(本体出现前的等待)。
|
||||
if err := b.BossReport(4978, 1, 7, now); err != nil {
|
||||
t.Fatalf("上半侧第二颗球: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 1 {
|
||||
t.Fatalf("打掉冰之后球池 = %d,want 1", got)
|
||||
}
|
||||
if got := b.Value("top boss state"); got != 9 {
|
||||
t.Fatalf("上半侧应当在等下半侧收尾(state=9),got %d", got)
|
||||
}
|
||||
if got := b.Value("bot boss state"); got != 8 {
|
||||
t.Fatalf("下半侧还在打风:state=%d,want 8", got)
|
||||
}
|
||||
// 下半侧清掉风:四颗全清 → 两侧都是 9,本体破防。
|
||||
if err := b.BossReport(4953, 1, 8, now); err != nil {
|
||||
t.Fatalf("下半侧第二颗球: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 0 {
|
||||
t.Fatalf("四颗球清完球池 = %d,want 0", got)
|
||||
}
|
||||
if b.Value("top boss state") != 9 || b.Value("bot boss state") != 9 {
|
||||
t.Fatalf("四颗球清完两侧应当都是 9:top=%d bot=%d",
|
||||
b.Value("top boss state"), b.Value("bot boss state"))
|
||||
}
|
||||
}
|
||||
|
||||
// 破防窗口没打完:官方口径是"两队血量均摊(向下取整)、整轮重开"。
|
||||
//
|
||||
// 「15秒内两队将各自的BOSS击杀即可顺利通关,如果未击杀成功,则会将BOSS剩余血量
|
||||
// 均摊给两个队伍,同上机制重新再打一轮即可。」
|
||||
// 「破防结束后两队会平衡血量,结果向下取整。」
|
||||
func TestFiendWarRoshanFailedWindowBalancesHPAndReopensThePattern(t *testing.T) {
|
||||
b, now := newFixtureBossBattle(t)
|
||||
b.PickClone = func(candidates []int) int { return 0 }
|
||||
b.resetBossPattern()
|
||||
openDamageWindow(t, b, now)
|
||||
// 上半侧把本体打到 0、下半侧一点没打 —— 真机 2026-10-02 13:15 就是"一侧 100、
|
||||
// 一侧 0",结果整轮重开、玩家看到"打完球 BOSS 不死、又开始出球"。
|
||||
b.set("top boss hp", 0)
|
||||
b.set("bot boss hp", 100)
|
||||
|
||||
if err := b.BossReport(4978, 2, 0, now); err != nil {
|
||||
t.Fatalf("上半侧破防上报: %v", err)
|
||||
}
|
||||
// 只有一侧报尾时不许动:另一侧还没报它那一轮的结果。
|
||||
if got := b.Value("boss pattern state"); got != 0 {
|
||||
t.Fatalf("只报一侧就重开了机制:球池 = %d,want 0", got)
|
||||
}
|
||||
if err := b.BossReport(4953, 2, 100, now); err != nil {
|
||||
t.Fatalf("下半侧破防上报: %v", err)
|
||||
}
|
||||
if got := b.Value("top boss hp"); got != 50 {
|
||||
t.Fatalf("血量均摊后上半侧 = %d,want 50", got)
|
||||
}
|
||||
if got := b.Value("bot boss hp"); got != 50 {
|
||||
t.Fatalf("血量均摊后下半侧 = %d,want 50", got)
|
||||
}
|
||||
if got := b.Value("top boss view hp"); got != 50 {
|
||||
t.Fatalf("战况表上的上半侧血量 = %d,want 50", got)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 1111 {
|
||||
t.Fatalf("没打完就该重开一轮:球池 = %d,want 1111", got)
|
||||
}
|
||||
if b.Value("top boss state") < 5 || b.Value("top boss state") > 8 {
|
||||
t.Fatalf("重开之后上半侧没有拿到球:state=%d", b.Value("top boss state"))
|
||||
}
|
||||
if b.Value("top boss state") == b.Value("bot boss state") {
|
||||
t.Fatalf("重开之后两侧拿了同一颗球:top=%d bot=%d",
|
||||
b.Value("top boss state"), b.Value("bot boss state"))
|
||||
}
|
||||
if b.BossWindowDone[0] || b.BossWindowDone[1] {
|
||||
t.Fatal("重开之后破防报尾标志没有清")
|
||||
}
|
||||
}
|
||||
|
||||
// 两队都在窗口里打死了本体:不许均摊、不许重开 —— 球池停在 0、两侧停在 9,
|
||||
// 剩下的交给客户端证死之后的那条通关路(PVF:state 9 + clear → 10,两个 10 → CLEAR PHASE)。
|
||||
func TestFiendWarRoshanWindowVictoryDoesNotReopenThePattern(t *testing.T) {
|
||||
b, now := newFixtureBossBattle(t)
|
||||
b.PickClone = func(candidates []int) int { return 0 }
|
||||
b.resetBossPattern()
|
||||
openDamageWindow(t, b, now)
|
||||
if err := b.BossReport(4978, 2, 0, now); err != nil {
|
||||
t.Fatalf("上半侧破防上报: %v", err)
|
||||
}
|
||||
if err := b.BossReport(4953, 2, 0, now); err != nil {
|
||||
t.Fatalf("下半侧破防上报: %v", err)
|
||||
}
|
||||
if got := b.Value("boss pattern state"); got != 0 {
|
||||
t.Fatalf("两队都打死了本体,机制却重开了:球池 = %d", got)
|
||||
}
|
||||
if b.Value("top boss state") != 9 || b.Value("bot boss state") != 9 {
|
||||
t.Fatalf("通关那一档两侧应当还在 9:top=%d bot=%d",
|
||||
b.Value("top boss state"), b.Value("bot boss state"))
|
||||
}
|
||||
if got := b.Value("top boss hp"); got != 0 {
|
||||
t.Fatalf("上半侧血量 = %d,want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// openDamageWindow 把四颗球报掉,让两侧都停在 9(本体破防)。
|
||||
func openDamageWindow(t *testing.T, b *FiendWarBattle, now time.Time) {
|
||||
t.Helper()
|
||||
for _, room := range []struct {
|
||||
dungeon int
|
||||
side string
|
||||
}{{4978, "top boss"}, {4953, "bot boss"}, {4978, "top boss"}, {4953, "bot boss"}} {
|
||||
state := b.Value(room.side + " state")
|
||||
if state < 5 || state > 8 {
|
||||
t.Fatalf("%s 没有球可打:state=%d", room.side, state)
|
||||
}
|
||||
if err := b.BossReport(room.dungeon, 1, state, now); err != nil {
|
||||
t.Fatalf("%s 机制上报 value=%d: %v", room.side, state, err)
|
||||
}
|
||||
}
|
||||
if b.Value("boss pattern state") != 0 {
|
||||
t.Fatalf("四颗球清完球池 = %d,want 0", b.Value("boss pattern state"))
|
||||
}
|
||||
}
|
||||
|
||||
func newFixtureBossBattle(t *testing.T) (*FiendWarBattle, time.Time) {
|
||||
t.Helper()
|
||||
config := &FiendWarBattleConfig{
|
||||
Raid: &AntonConfig{},
|
||||
Areas: map[int]FiendWarArea{
|
||||
30: {Index: 30, Dungeon: 4978, Kind: "top boss", Symbol: 200},
|
||||
31: {Index: 31, Dungeon: 4953, Kind: "bot boss", Symbol: 201},
|
||||
},
|
||||
Symbols: map[string]uint32{
|
||||
"top boss state": 200, "bot boss state": 201,
|
||||
"top boss hp": 202, "bot boss hp": 203,
|
||||
"top boss view hp": 204, "bot boss view hp": 205,
|
||||
"boss pattern state": 206,
|
||||
},
|
||||
Rules: []FiendWarRule{{}},
|
||||
}
|
||||
b, err := NewFiendWarBattle(config)
|
||||
if err != nil {
|
||||
t.Fatalf("fixture 战场: %v", err)
|
||||
}
|
||||
b.Areas[30] = FiendWarAreaState{Group: 1}
|
||||
b.Areas[31] = FiendWarAreaState{Group: 2}
|
||||
return b, time.Unix(0, 0)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// 本文件把已确认的攻坚队 C2S 命令纳入模块化分流。
|
||||
package raid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
// NewHandler 创建攻坚队协议处理器。
|
||||
func NewHandler() alignedcmd.Handler {
|
||||
return handler{}
|
||||
}
|
||||
|
||||
// Domain 返回攻坚队业务域。
|
||||
func (handler) Domain() dnfenum.AlignedDomain {
|
||||
return dnfenum.AlignedDomainRaid
|
||||
}
|
||||
|
||||
// Handle 解析已确认的攻坚队 C2S 命令,并禁止模块层伪造成攻坚成功;在线刷新由 dnfbridge/后续 raid owner 推 0x24F。
|
||||
func (handler) Handle(_ context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
switch dnfenum.CmdPacket(req.Opcode) {
|
||||
case dnfenum.CmdPacketCreateRaid:
|
||||
return handleCreateRaid(req), nil
|
||||
case dnfenum.CmdPacketLeaveRaid:
|
||||
return handleLeaveRaid(req), nil
|
||||
case dnfenum.CmdPacketStartRaid:
|
||||
return handleStartRaid(req), nil
|
||||
case dnfenum.CmdPacketRaidEntryCostInfo:
|
||||
return handleEntryCostInfo(req), nil
|
||||
case dnfenum.CmdPacketRaidSetSymbol:
|
||||
return handleSetSymbol(req), nil
|
||||
case dnfenum.CmdPacketSetRaidWaiting:
|
||||
return handleSetWaiting(req), nil
|
||||
case dnfenum.CmdPacketRejoinRaid:
|
||||
return handleRejoinRaid(req), nil
|
||||
case dnfenum.CmdPacketRaidManagerWork:
|
||||
return handleManagerWork(req), nil
|
||||
case dnfenum.CmdPacketModifyRaidInfo:
|
||||
return handleModifyRaidInfo(req), nil
|
||||
case dnfenum.CmdPacketRaidOtherChannelRequestJoin:
|
||||
return handleOtherChannelRequestJoin(req), nil
|
||||
case dnfenum.CmdPacketRaidMemberChangeState:
|
||||
return handleMemberChangeState(req), nil
|
||||
case dnfenum.CmdPacketRaidUserMoveChannelFail:
|
||||
return handleUserMoveChannelFail(req), nil
|
||||
case dnfenum.CmdPacketRaidOtherChannelList:
|
||||
return handleOtherChannelList(req), nil
|
||||
case dnfenum.CmdPacketRaidCheckRaidUser:
|
||||
return handleCheckRaidUser(req), nil
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("raid 模块未登记 opcode %d", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateRaid(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeCreateRaidRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_create", err)
|
||||
}
|
||||
return raidPending("raid_create", fmt.Sprintf(
|
||||
"MCP 20260705:664 创建攻坚队 body=u8,dstr name,u8 tail;route=%d name_len=%d tail=%d;真实成功后应推 0x24F mode=3",
|
||||
parsed.RouteOrRaidType,
|
||||
len(parsed.NameBytes),
|
||||
parsed.TailFlag,
|
||||
))
|
||||
}
|
||||
|
||||
func handleLeaveRaid(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeLeaveRaidRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_leave", err)
|
||||
}
|
||||
return raidPending("raid_leave", fmt.Sprintf(
|
||||
"MCP 20260705:665 离开攻坚队 body=u16 key=%d;真实成功后应推 0x24F mode=3 或清空攻坚 UI",
|
||||
parsed.RaidOrMemberKey,
|
||||
))
|
||||
}
|
||||
|
||||
func handleStartRaid(req alignedcmd.Request) alignedcmd.Result {
|
||||
if err := DecodeStartRaidRequest(req.Body); err != nil {
|
||||
return raidPendingParseError("raid_start", err)
|
||||
}
|
||||
return raidPending("raid_start", "MCP 20260705:666 开始攻坚为空包;服务端开始后按 group_index/slot_order 自动创建普通 4 人小队")
|
||||
}
|
||||
|
||||
func handleRejoinRaid(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeRejoinRaidRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_rejoin", err)
|
||||
}
|
||||
return raidPending("raid_rejoin", fmt.Sprintf(
|
||||
"MCP 20260705:668 重新加入攻坚 body=u32 raid_key=0x%08X;后续由服务端重推 0x24F mode=3",
|
||||
parsed.RaidKey,
|
||||
))
|
||||
}
|
||||
|
||||
func handleSetWaiting(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeSetWaitingRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_set_waiting", err)
|
||||
}
|
||||
return raidPending("raid_set_waiting", fmt.Sprintf(
|
||||
"MCP 20260705:667 攻坚等待状态 body=u8 flag,u8 route;flag=%d route=%d;对象级刷新待 0x24F mode=2 继续确认",
|
||||
parsed.Flag,
|
||||
parsed.RouteRaidType,
|
||||
))
|
||||
}
|
||||
|
||||
func handleEntryCostInfo(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeEntryCostInfoRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_entry_cost_info", err)
|
||||
}
|
||||
return raidPending("raid_entry_cost_info", fmt.Sprintf(
|
||||
"MCP 20260705: 658 RaidEntryCostInfo body=u8 enabled=%d;这是入场消耗/材料信息请求,未确认 S2C 成功结构前不自动回包",
|
||||
parsed.Enabled,
|
||||
))
|
||||
}
|
||||
|
||||
func handleSetSymbol(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeSetSymbolRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_set_symbol", err)
|
||||
}
|
||||
return raidPending("raid_set_symbol", fmt.Sprintf(
|
||||
"MCP 20260705: 662 RaidSetSymbol body=u32,u32,u8;source=0x%08X target=0x%08X symbol=%d;先登记,不伪造 raid 标记同步",
|
||||
parsed.SourceValue,
|
||||
parsed.TargetValue,
|
||||
parsed.Symbol,
|
||||
))
|
||||
}
|
||||
|
||||
func handleManagerWork(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeManagerWorkRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_manager_work", err)
|
||||
}
|
||||
return raidPending("raid_manager_work", fmt.Sprintf(
|
||||
"MCP 20260705:669 仅是攻坚队长编辑请求,S2C 669 为 DoNothing;需更新成员 %d 到小组 %d 后推 0x24F mode=3",
|
||||
parsed.MemberCharKey,
|
||||
parsed.TargetGroup,
|
||||
))
|
||||
}
|
||||
|
||||
func handleOtherChannelRequestJoin(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeOtherChannelRequestJoinRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_other_channel_request_join", err)
|
||||
}
|
||||
return raidPending("raid_other_channel_request_join", fmt.Sprintf(
|
||||
"MCP 20260705:820 跨频道攻坚申请 body=u8,u16,u32,u16;mode=%d target=%d client=0x%08X route=%d;需要公共频道 owner 决定回包",
|
||||
parsed.Mode,
|
||||
parsed.TargetKey,
|
||||
parsed.ClientValue,
|
||||
parsed.RouteRaidType,
|
||||
))
|
||||
}
|
||||
|
||||
func handleMemberChangeState(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeMemberChangeStateRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_member_change_state", err)
|
||||
}
|
||||
return raidPending("raid_member_change_state", fmt.Sprintf(
|
||||
"MCP 20260705:823 成员状态变化 body=u8 state=%d;在线实现更新 member state 后推 0x24F mode=3",
|
||||
parsed.State,
|
||||
))
|
||||
}
|
||||
|
||||
func handleUserMoveChannelFail(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeUserMoveChannelFailRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_user_move_channel_fail", err)
|
||||
}
|
||||
return raidPending("raid_user_move_channel_fail", fmt.Sprintf(
|
||||
"MCP 20260705:824 跨频道移动失败 body=u8,u16;mode=%d target=%d;只记录,不伪造成功",
|
||||
parsed.Mode,
|
||||
parsed.TargetKey,
|
||||
))
|
||||
}
|
||||
|
||||
func handleOtherChannelList(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeOtherChannelListRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_other_channel_list", err)
|
||||
}
|
||||
return raidPending("raid_other_channel_list", fmt.Sprintf(
|
||||
"MCP 20260705:831 公共频道攻坚列表 body=u8 或 u8,raw8,dstr;mode=%d has_ctx=%v name_len=%d;列表回包待公共频道 owner",
|
||||
parsed.Mode,
|
||||
parsed.HasContext,
|
||||
len(parsed.NameBytes),
|
||||
))
|
||||
}
|
||||
|
||||
func handleCheckRaidUser(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeCheckRaidUserRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_check_user", err)
|
||||
}
|
||||
return raidPending("raid_check_user", fmt.Sprintf(
|
||||
"MCP 20260705:889 攻坚用户检查 body=u8,u16;mode=%d target=%d;S2C 889 是复杂列表,禁止最小假成功",
|
||||
parsed.Mode,
|
||||
parsed.TargetKey,
|
||||
))
|
||||
}
|
||||
|
||||
func handleModifyRaidInfo(req alignedcmd.Request) alignedcmd.Result {
|
||||
parsed, err := DecodeModifyRaidInfoRequest(req.Body)
|
||||
if err != nil {
|
||||
return raidPendingParseError("raid_modify_info", err)
|
||||
}
|
||||
return raidPending("raid_modify_info", fmt.Sprintf(
|
||||
"MCP 20260705:670 修改攻坚信息 body=u8,dstr name,u8 tail;route=%d name_len=%d tail=%d;真实成功后应推 0x24F mode=3",
|
||||
parsed.RouteOrRaidType,
|
||||
len(parsed.NameBytes),
|
||||
parsed.TailFlag,
|
||||
))
|
||||
}
|
||||
|
||||
func raidPendingParseError(operation string, err error) alignedcmd.Result {
|
||||
return raidPending(operation, fmt.Sprintf("%s 请求体解析失败:%v;禁止伪造成攻坚成功", operation, err))
|
||||
}
|
||||
|
||||
func raidPending(operation string, reason string) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// LukeCardDisplayMsgID is the tarot display notification the client's
|
||||
// CardSetWindow is driven by: opcode 1181 (0x49D), LUKE_RAID_CARD_DISPLAY.
|
||||
//
|
||||
// It is a server push. The name table lists no C2S counterpart -- every
|
||||
// LUKE_RAID_* packet is an ENUM_NOTIPACKET_* -- and the handler at 0x010CDB90
|
||||
// is registered by 0x010CDF50, so nothing the client sends can produce one.
|
||||
const LukeCardDisplayMsgID uint16 = 1181
|
||||
|
||||
// The body is a single record with no outer count:
|
||||
//
|
||||
// u8 cardSetIndex index of the day's [CARD SET MAP INFO] record; the
|
||||
// client only checks it against the record count and
|
||||
// drops the WHOLE packet when it is out of range
|
||||
// u8 N number of card records (one per phase-one sanctum = 3)
|
||||
// N x {
|
||||
// u16 A sanctum dungeon id (5054 / 5055 / 5056)
|
||||
// u8 B that sanctum's [PATTERN] index in LukeCardSet.etc
|
||||
// u8 K card slots of that pattern = its [CARD POS] pair count
|
||||
// K x {
|
||||
// u32 P the tarot card of this slot: a [CARD] monster id
|
||||
// u8 Q read but never consumed by this client build; must be
|
||||
// sent, or every following field shifts
|
||||
// }
|
||||
// }
|
||||
// u8 openWindowFlag non-zero pops the CardSetWindow on its own
|
||||
//
|
||||
// Field widths are pinned by the handler's read helpers: 1 byte 0x34E3970,
|
||||
// 2 bytes 0x34E39E0, 4 bytes 0x34E3A50 (tooling: tools\_rev_卢克塔罗牌_1181规格_20260929.md
|
||||
// §1.3). There are no coordinates in the body -- the client takes those from
|
||||
// `[CARD POS]` in LukeCardSet.etc itself.
|
||||
const (
|
||||
// lukeCardDisplayMaxRecords bounds N: it is a u8 on the wire.
|
||||
lukeCardDisplayMaxRecords = 255
|
||||
// lukeCardDisplaySlotPadding is the Q byte of every slot. This client build
|
||||
// reads it into [ebp-0x12] and never looks at it again; it is emitted as a
|
||||
// fixed zero rather than guessed at.
|
||||
lukeCardDisplaySlotPadding byte = 0
|
||||
)
|
||||
|
||||
var ErrLukeCardDisplayShapeInvalid = errors.New("luke card display shape is invalid")
|
||||
|
||||
// LukeCardDisplaySanctum is one card record: which sanctum, which of its
|
||||
// patterns, and the tarot card sitting in each of that pattern's slots.
|
||||
type LukeCardDisplaySanctum struct {
|
||||
// DungeonID is field A. It must be one of the sanctum ids the 1181 handler
|
||||
// can resolve in LukeCardSet.etc, or the window draws nothing for it.
|
||||
DungeonID uint32
|
||||
// Pattern is field B: the index half of that sanctum's `[PATTERN]` key.
|
||||
Pattern int
|
||||
// Cards is the P field of every slot, in slot order. Its length is K, and it
|
||||
// must equal the pattern's `[CARD POS]` pair count: the client walks the slot
|
||||
// coordinates and the P list together, so a short list reads past the end.
|
||||
Cards []uint32
|
||||
}
|
||||
|
||||
// LukeCardDisplay is one 1181 body before encoding.
|
||||
type LukeCardDisplay struct {
|
||||
// CardSetIndex is the day's `[CARD SET MAP INFO]` record index.
|
||||
CardSetIndex byte
|
||||
// CardSetCount is how many such records the client holds, i.e. the exclusive
|
||||
// upper bound the client validates CardSetIndex against. It comes from
|
||||
// luke.etc's `[CARD SET MAP INFO]` (5 records = 15 values / 3) and the
|
||||
// builder refuses to emit a body the client would drop whole.
|
||||
CardSetCount int
|
||||
// Sanctums is N records, one per phase-one sanctum, in wire order.
|
||||
Sanctums []LukeCardDisplaySanctum
|
||||
// OpenWindow asks the client to open the CardSetWindow itself.
|
||||
OpenWindow bool
|
||||
}
|
||||
|
||||
// LukeCardDisplayBodySize is the exact body length of that shape, so a caller
|
||||
// can size a buffer without encoding it first.
|
||||
func LukeCardDisplayBodySize(sanctums []LukeCardDisplaySanctum) int {
|
||||
size := 2 + 1 // cardSetIndex, N, openWindowFlag
|
||||
for _, sanctum := range sanctums {
|
||||
size += 4 + 5*len(sanctum.Cards) // A, B, K, then K x (u32 P, u8 Q)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// BuildLukeCardDisplayBody encodes one 1181 body.
|
||||
//
|
||||
// It refuses anything the client would silently mis-draw instead of sending a
|
||||
// half-described packet: an out-of-range cardSetIndex (the client drops the
|
||||
// whole packet), a non-positive N or K, a dungeon id that does not fit its u16
|
||||
// field, a zero card id (the client reads a missing key as an empty slot, so a
|
||||
// zero is always a caller bug), or a card count above the u8 field width.
|
||||
func BuildLukeCardDisplayBody(display LukeCardDisplay) ([]byte, error) {
|
||||
if display.CardSetCount <= 0 {
|
||||
return nil, fmt.Errorf("%w: card_set_count=%d want_positive", ErrLukeCardDisplayShapeInvalid, display.CardSetCount)
|
||||
}
|
||||
if int(display.CardSetIndex) >= display.CardSetCount {
|
||||
return nil, fmt.Errorf("%w: card_set_index=%d card_set_count=%d",
|
||||
ErrLukeCardDisplayShapeInvalid, display.CardSetIndex, display.CardSetCount)
|
||||
}
|
||||
if len(display.Sanctums) == 0 || len(display.Sanctums) > lukeCardDisplayMaxRecords {
|
||||
return nil, fmt.Errorf("%w: records=%d want=1..%d",
|
||||
ErrLukeCardDisplayShapeInvalid, len(display.Sanctums), lukeCardDisplayMaxRecords)
|
||||
}
|
||||
for index, sanctum := range display.Sanctums {
|
||||
if sanctum.DungeonID == 0 || sanctum.DungeonID > math.MaxUint16 {
|
||||
return nil, fmt.Errorf("%w: record=%d dungeon=%d want=1..%d",
|
||||
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, math.MaxUint16)
|
||||
}
|
||||
if sanctum.Pattern < 0 || sanctum.Pattern > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("%w: record=%d dungeon=%d pattern=%d want=0..%d",
|
||||
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, sanctum.Pattern, math.MaxUint8)
|
||||
}
|
||||
if len(sanctum.Cards) == 0 || len(sanctum.Cards) > lukeCardDisplayMaxRecords {
|
||||
return nil, fmt.Errorf("%w: record=%d dungeon=%d slots=%d want=1..%d",
|
||||
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, len(sanctum.Cards), lukeCardDisplayMaxRecords)
|
||||
}
|
||||
for slot, cardID := range sanctum.Cards {
|
||||
if cardID == 0 {
|
||||
return nil, fmt.Errorf("%w: record=%d dungeon=%d slot=%d card=0",
|
||||
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer := packetWriter{buf: make([]byte, 0, LukeCardDisplayBodySize(display.Sanctums))}
|
||||
writer.writeByte(display.CardSetIndex)
|
||||
writer.writeByte(byte(len(display.Sanctums)))
|
||||
for _, sanctum := range display.Sanctums {
|
||||
writer.writeUint16(uint16(sanctum.DungeonID))
|
||||
writer.writeByte(byte(sanctum.Pattern))
|
||||
writer.writeByte(byte(len(sanctum.Cards)))
|
||||
for _, cardID := range sanctum.Cards {
|
||||
writer.writeUint32(cardID)
|
||||
writer.writeByte(lukeCardDisplaySlotPadding)
|
||||
}
|
||||
}
|
||||
var openWindow byte
|
||||
if display.OpenWindow {
|
||||
openWindow = 1
|
||||
}
|
||||
writer.writeByte(openWindow)
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lukeCardDisplayTestSanctums is the shipped phase-one shape: three sanctums,
|
||||
// each with its own pattern index and seven card slots.
|
||||
func lukeCardDisplayTestSanctums() []LukeCardDisplaySanctum {
|
||||
return []LukeCardDisplaySanctum{
|
||||
{
|
||||
DungeonID: 5054,
|
||||
Pattern: 1,
|
||||
Cards: []uint32{59171, 59098, 59873, 59096, 56065, 59874, 56699},
|
||||
},
|
||||
{
|
||||
DungeonID: 5055,
|
||||
Pattern: 2,
|
||||
Cards: []uint32{59278, 56166, 56673, 59875, 56066, 56674, 56067},
|
||||
},
|
||||
{
|
||||
DungeonID: 5056,
|
||||
Pattern: 0,
|
||||
Cards: []uint32{56068, 56063, 56700, 57156, 59872, 59099, 59095},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLukeCardDisplayBodyMatchesTheReverseEngineeredWireBytes is the
|
||||
// byte-exact pin for opcode 1181. Every expected byte below is written out by
|
||||
// hand from the spec's field table (tools\_rev_卢克塔罗牌_1181规格_20260929.md
|
||||
// §一) rather than re-encoded by the builder:
|
||||
//
|
||||
// u8 cardSetIndex = 3
|
||||
// u8 N = 3
|
||||
// per record: u16 A (little endian), u8 B, u8 K, then K x (u32 P little
|
||||
// endian, u8 Q = 0)
|
||||
// u8 openWindowFlag = 1
|
||||
//
|
||||
// 5054 = 0x13BE, 5055 = 0x13BF, 5056 = 0x13C0; the card ids are the 22
|
||||
// `[CARD]` monster ids, 59171 = 0x0000E723 and so on.
|
||||
func TestBuildLukeCardDisplayBodyMatchesTheReverseEngineeredWireBytes(t *testing.T) {
|
||||
body, err := BuildLukeCardDisplayBody(LukeCardDisplay{
|
||||
CardSetIndex: 3,
|
||||
CardSetCount: 5,
|
||||
Sanctums: lukeCardDisplayTestSanctums(),
|
||||
OpenWindow: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLukeCardDisplayBody error = %v", err)
|
||||
}
|
||||
const wantHex = "" +
|
||||
// cardSetIndex, N
|
||||
"0303" +
|
||||
// 5054, pattern 1, K=7, then the seven cards each followed by Q=0
|
||||
"be13" + "01" + "07" +
|
||||
"23e70000" + "00" +
|
||||
"dae60000" + "00" +
|
||||
"e1e90000" + "00" +
|
||||
"d8e60000" + "00" +
|
||||
"01db0000" + "00" +
|
||||
"e2e90000" + "00" +
|
||||
"7bdd0000" + "00" +
|
||||
// 5055, pattern 2, K=7
|
||||
"bf13" + "02" + "07" +
|
||||
"8ee70000" + "00" +
|
||||
"66db0000" + "00" +
|
||||
"61dd0000" + "00" +
|
||||
"e3e90000" + "00" +
|
||||
"02db0000" + "00" +
|
||||
"62dd0000" + "00" +
|
||||
"03db0000" + "00" +
|
||||
// 5056, pattern 0, K=7
|
||||
"c013" + "00" + "07" +
|
||||
"04db0000" + "00" +
|
||||
"ffda0000" + "00" +
|
||||
"7cdd0000" + "00" +
|
||||
"44df0000" + "00" +
|
||||
"e0e90000" + "00" +
|
||||
"dbe60000" + "00" +
|
||||
"d7e60000" + "00" +
|
||||
// openWindowFlag
|
||||
"01"
|
||||
want, err := hex.DecodeString(wantHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode want hex: %v", err)
|
||||
}
|
||||
if len(body) != len(want) {
|
||||
t.Fatalf("body_len=%d want=%d", len(body), len(want))
|
||||
}
|
||||
if got := hex.EncodeToString(body); got != wantHex {
|
||||
t.Fatalf("body=%s\nwant=%s", got, wantHex)
|
||||
}
|
||||
if size := LukeCardDisplayBodySize(lukeCardDisplayTestSanctums()); size != len(want) {
|
||||
t.Fatalf("LukeCardDisplayBodySize=%d want=%d", size, len(want))
|
||||
}
|
||||
|
||||
// Re-read the body field by field, in the client's own order, so a change in
|
||||
// any field width fails here with the offset rather than only as a hex diff.
|
||||
if body[0] != 3 {
|
||||
t.Fatalf("cardSetIndex=%d want 3", body[0])
|
||||
}
|
||||
if body[1] != 3 {
|
||||
t.Fatalf("N=%d want 3", body[1])
|
||||
}
|
||||
offset := 2
|
||||
for index, want := range lukeCardDisplayTestSanctums() {
|
||||
if got := binary.LittleEndian.Uint16(body[offset : offset+2]); uint32(got) != want.DungeonID {
|
||||
t.Fatalf("record %d A@%d=%d want %d", index, offset, got, want.DungeonID)
|
||||
}
|
||||
offset += 2
|
||||
if int(body[offset]) != want.Pattern {
|
||||
t.Fatalf("record %d B@%d=%d want %d", index, offset, body[offset], want.Pattern)
|
||||
}
|
||||
offset++
|
||||
if int(body[offset]) != 7 {
|
||||
t.Fatalf("record %d K@%d=%d want 7", index, offset, body[offset])
|
||||
}
|
||||
offset++
|
||||
for slot, cardID := range want.Cards {
|
||||
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != cardID {
|
||||
t.Fatalf("record %d slot %d P@%d=%d want %d", index, slot, offset, got, cardID)
|
||||
}
|
||||
offset += 4
|
||||
if body[offset] != 0 {
|
||||
t.Fatalf("record %d slot %d Q@%d=%d want 0", index, slot, offset, body[offset])
|
||||
}
|
||||
offset++
|
||||
}
|
||||
}
|
||||
if offset != len(body)-1 {
|
||||
t.Fatalf("openWindowFlag offset=%d want=%d", offset, len(body)-1)
|
||||
}
|
||||
if body[len(body)-1] != 1 {
|
||||
t.Fatalf("openWindowFlag=%d want 1", body[len(body)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLukeCardDisplayBodyKeepsOpenWindowOffWhenNotAsked pins the trailing
|
||||
// byte's other value: only the waiting-area push sets it, later re-reads do not.
|
||||
func TestBuildLukeCardDisplayBodyKeepsOpenWindowOffWhenNotAsked(t *testing.T) {
|
||||
body, err := BuildLukeCardDisplayBody(LukeCardDisplay{
|
||||
CardSetIndex: 0,
|
||||
CardSetCount: 5,
|
||||
Sanctums: []LukeCardDisplaySanctum{{DungeonID: 5054, Pattern: 0, Cards: []uint32{59171}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLukeCardDisplayBody error = %v", err)
|
||||
}
|
||||
want := []byte{0x00, 0x01, 0xbe, 0x13, 0x00, 0x01, 0x23, 0xe7, 0x00, 0x00, 0x00, 0x00}
|
||||
if got := hex.EncodeToString(body); got != hex.EncodeToString(want) {
|
||||
t.Fatalf("body=%s want=%s", got, hex.EncodeToString(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLukeCardDisplayBodyRefusesBodiesTheClientWouldDrop covers the
|
||||
// out-of-range card set index (the handler throws the WHOLE packet away) and the
|
||||
// shapes that would make the client read past the end of a record.
|
||||
func TestBuildLukeCardDisplayBodyRefusesBodiesTheClientWouldDrop(t *testing.T) {
|
||||
base := func() LukeCardDisplay {
|
||||
return LukeCardDisplay{
|
||||
CardSetIndex: 4,
|
||||
CardSetCount: 5,
|
||||
Sanctums: lukeCardDisplayTestSanctums(),
|
||||
OpenWindow: true,
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*LukeCardDisplay)
|
||||
}{
|
||||
{"card set index at the bound", func(d *LukeCardDisplay) { d.CardSetIndex = 5 }},
|
||||
{"unknown card set count", func(d *LukeCardDisplay) { d.CardSetCount = 0 }},
|
||||
{"no records", func(d *LukeCardDisplay) { d.Sanctums = nil }},
|
||||
{"zero dungeon id", func(d *LukeCardDisplay) { d.Sanctums[0].DungeonID = 0 }},
|
||||
{"negative pattern", func(d *LukeCardDisplay) { d.Sanctums[0].Pattern = -1 }},
|
||||
{"pattern above the u8", func(d *LukeCardDisplay) { d.Sanctums[0].Pattern = 256 }},
|
||||
{"no slots", func(d *LukeCardDisplay) { d.Sanctums[0].Cards = nil }},
|
||||
{"zero card id", func(d *LukeCardDisplay) { d.Sanctums[0].Cards[3] = 0 }},
|
||||
{"too many records", func(d *LukeCardDisplay) {
|
||||
records := make([]LukeCardDisplaySanctum, 256)
|
||||
for index := range records {
|
||||
records[index] = LukeCardDisplaySanctum{DungeonID: 5054, Cards: []uint32{59171}}
|
||||
}
|
||||
d.Sanctums = records
|
||||
}},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
display := base()
|
||||
testCase.mutate(&display)
|
||||
if _, err := BuildLukeCardDisplayBody(display); !errors.Is(err, ErrLukeCardDisplayShapeInvalid) {
|
||||
t.Fatalf("%s: err=%v want %v", testCase.name, err, ErrLukeCardDisplayShapeInvalid)
|
||||
}
|
||||
}
|
||||
if _, err := BuildLukeCardDisplayBody(base()); err != nil {
|
||||
t.Fatalf("the unmutated shape must still encode: %v", err)
|
||||
}
|
||||
if LukeCardDisplayMsgID != 1181 {
|
||||
t.Fatalf("LukeCardDisplayMsgID=%d want 1181", LukeCardDisplayMsgID)
|
||||
}
|
||||
if strings.TrimSpace(LukeCardSetPVFPath) != "etc/raid/luke/LukeCardSet.etc" {
|
||||
t.Fatalf("LukeCardSetPVFPath=%q", LukeCardSetPVFPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// LukeCardSetPVFPath is the tarot table the client's CardSetWindow draws from.
|
||||
//
|
||||
// It is a document of its own: luke.etc owns the phase graph and the
|
||||
// [CARD SET MAP INFO] record list, while this file owns the 8 pattern slots and
|
||||
// the 22 major arcana. The client loads it into LukeCardSet (window+0x1a8) and
|
||||
// looks up the notification's (dungeonID, patternIndex) pair in the pattern map
|
||||
// at config+0x14 (tools\_rev_卢克塔罗牌_1181规格_20260929.md §7.2), and each
|
||||
// card slot's P in the card map at config+0x00 (§7.3, §8.2).
|
||||
const LukeCardSetPVFPath = "etc/raid/luke/LukeCardSet.etc"
|
||||
|
||||
var (
|
||||
ErrLukeCardSetSourceRequired = errors.New("luke card set PVF source is required")
|
||||
ErrLukeCardSetDocumentInvalid = errors.New("luke card set PVF document is invalid")
|
||||
)
|
||||
|
||||
// LukeCardPosition is one `[CARD POS]` (x, y) pair.
|
||||
//
|
||||
// Coordinates sit on a 0..4 grid in half steps (0, 0.50, 1, 1.50 ...), which is
|
||||
// why they are floats and not ints. The client reads them from this table
|
||||
// itself -- the notification body never carries coordinates -- so they are
|
||||
// parsed here for counting (the slot count K) and for diagnostics only.
|
||||
type LukeCardPosition struct {
|
||||
X float64
|
||||
Y float64
|
||||
}
|
||||
|
||||
// LukeCardPattern is one `[PATTERN] <dungeonID> <patternIndex>` block, the tarot
|
||||
// display of one phase-one sanctum: which buff-system icon it shows, where its
|
||||
// seven card slots sit, and the tooltip the window prints.
|
||||
//
|
||||
// patternIndex is the notification's field B and dungeonID its field A; together
|
||||
// they are the 8-byte key the client's pattern map is built from (the two dwords
|
||||
// pushed at 0x03926BD6).
|
||||
type LukeCardPattern struct {
|
||||
DungeonID uint32
|
||||
Index int
|
||||
ImagePath string
|
||||
ImageIndices []int
|
||||
CardPositions []LukeCardPosition
|
||||
Tooltip string
|
||||
}
|
||||
|
||||
// LukeCard is one `[CARD] <monsterID>` block: a major arcanum. MonsterID is the
|
||||
// u32 the notification puts in every card slot (field P).
|
||||
type LukeCard struct {
|
||||
MonsterID uint32
|
||||
ImagePath string
|
||||
ImageIndex int
|
||||
Tooltip string
|
||||
}
|
||||
|
||||
// LukeCardSetConfig owns etc/raid/luke/LukeCardSet.etc. Both tables are global:
|
||||
// the 22 cards are not per-pattern (the `[PATTERN]` blocks carry no `[CARD]`
|
||||
// child at all), and the `[CARD POS]` pairs are the seven slots *inside* one
|
||||
// pattern, not seven cards.
|
||||
type LukeCardSetConfig struct {
|
||||
// Patterns is every `[PATTERN]` block in source order.
|
||||
Patterns []LukeCardPattern
|
||||
// Cards is every `[CARD]` block in source order (the 22 major arcana).
|
||||
Cards []LukeCard
|
||||
// cardsByMonsterID indexes Cards by the notification's P field. The client
|
||||
// silently draws nothing for a P that misses, so the caller must send only
|
||||
// keys that are present here.
|
||||
cardsByMonsterID map[uint32]int
|
||||
}
|
||||
|
||||
func LoadLukeCardSet(source dnfpvf.Source) (*LukeCardSetConfig, error) {
|
||||
if source == nil {
|
||||
return nil, ErrLukeCardSetSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(LukeCardSetPVFPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read luke card set PVF %q: %w", LukeCardSetPVFPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(LukeCardSetPVFPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse luke card set PVF %q: %w", LukeCardSetPVFPath, err)
|
||||
}
|
||||
return ParseLukeCardSet(document)
|
||||
}
|
||||
|
||||
func ParseLukeCardSet(document *dnfpvf.Document) (*LukeCardSetConfig, error) {
|
||||
if document == nil || antonPathKey(document.Path) != antonPathKey(LukeCardSetPVFPath) {
|
||||
path := ""
|
||||
if document != nil {
|
||||
path = document.Path
|
||||
}
|
||||
return nil, fmt.Errorf("%w: path=%q want=%q", ErrLukeCardSetDocumentInvalid, path, LukeCardSetPVFPath)
|
||||
}
|
||||
patternBlocks, err := antonFindBlocks(document, "pattern", 0, len(document.Sections))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: pattern blocks: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
if len(patternBlocks) == 0 {
|
||||
return nil, fmt.Errorf("%w: no [PATTERN] block", ErrLukeCardSetDocumentInvalid)
|
||||
}
|
||||
config := &LukeCardSetConfig{
|
||||
Patterns: make([]LukeCardPattern, 0, len(patternBlocks)),
|
||||
cardsByMonsterID: make(map[uint32]int, 32),
|
||||
}
|
||||
// The client's pattern map holds one entry per `[PATTERN]` block and a
|
||||
// duplicate key would overwrite rather than extend it, so a repeated
|
||||
// (dungeonID, index) pair is a table this server cannot describe faithfully.
|
||||
patternKeys := make(map[[2]int64]struct{}, len(patternBlocks))
|
||||
for _, block := range patternBlocks {
|
||||
pattern, parseErr := parseLukeCardPattern(document, block)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
key := [2]int64{int64(pattern.DungeonID), int64(pattern.Index)}
|
||||
if _, duplicate := patternKeys[key]; duplicate {
|
||||
return nil, fmt.Errorf("%w: duplicate [PATTERN] dungeon=%d index=%d",
|
||||
ErrLukeCardSetDocumentInvalid, pattern.DungeonID, pattern.Index)
|
||||
}
|
||||
patternKeys[key] = struct{}{}
|
||||
config.Patterns = append(config.Patterns, pattern)
|
||||
}
|
||||
|
||||
cardBlocks, err := antonFindBlocks(document, "card", 0, len(document.Sections))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: card blocks: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
if len(cardBlocks) == 0 {
|
||||
return nil, fmt.Errorf("%w: no [CARD] block", ErrLukeCardSetDocumentInvalid)
|
||||
}
|
||||
for _, block := range cardBlocks {
|
||||
card, parseErr := parseLukeCard(document, block)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if _, duplicate := config.cardsByMonsterID[card.MonsterID]; duplicate {
|
||||
return nil, fmt.Errorf("%w: duplicate [CARD] monster=%d", ErrLukeCardSetDocumentInvalid, card.MonsterID)
|
||||
}
|
||||
config.cardsByMonsterID[card.MonsterID] = len(config.Cards)
|
||||
config.Cards = append(config.Cards, card)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Pattern returns the pattern of one sanctum and pattern index.
|
||||
func (c *LukeCardSetConfig) Pattern(dungeonID uint32, index int) (LukeCardPattern, bool) {
|
||||
if c == nil {
|
||||
return LukeCardPattern{}, false
|
||||
}
|
||||
for _, pattern := range c.Patterns {
|
||||
if pattern.DungeonID == dungeonID && pattern.Index == index {
|
||||
return pattern, true
|
||||
}
|
||||
}
|
||||
return LukeCardPattern{}, false
|
||||
}
|
||||
|
||||
// PatternIndices lists one sanctum's pattern indices in ascending order: the
|
||||
// index positions a caller may pick a pattern from. 5054 -> [0 1],
|
||||
// 5055 -> [0 1 2 3], 5056 -> [0 1] in the shipped PVF.
|
||||
func (c *LukeCardSetConfig) PatternIndices(dungeonID uint32) []int {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
indices := make([]int, 0, 4)
|
||||
for _, pattern := range c.Patterns {
|
||||
if pattern.DungeonID == dungeonID {
|
||||
indices = append(indices, pattern.Index)
|
||||
}
|
||||
}
|
||||
sort.Ints(indices)
|
||||
return indices
|
||||
}
|
||||
|
||||
// CardMonsterIDs returns every `[CARD]` key in source order: the only values the
|
||||
// notification's P field may carry.
|
||||
func (c *LukeCardSetConfig) CardMonsterIDs() []uint32 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint32, 0, len(c.Cards))
|
||||
for _, card := range c.Cards {
|
||||
ids = append(ids, card.MonsterID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// HasCard reports whether a monster id is one of the `[CARD]` keys.
|
||||
func (c *LukeCardSetConfig) HasCard(monsterID uint32) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := c.cardsByMonsterID[monsterID]; ok {
|
||||
return true
|
||||
}
|
||||
// A config assembled without the loader (tests, or a caller mirroring another
|
||||
// document) has no index built; fall back to the blocks themselves, the same
|
||||
// way Pattern and PatternIndices read their slices directly.
|
||||
for _, card := range c.Cards {
|
||||
if card.MonsterID == monsterID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseLukeCardPattern reads one `[PATTERN] <dungeonID> <index>` block: its two
|
||||
// key values sit on the opening section, and only [IMAGE] / [CARD POS] /
|
||||
// [TOOLTIP TEXT] may appear inside.
|
||||
func parseLukeCardPattern(document *dnfpvf.Document, block antonSectionBlock) (LukeCardPattern, error) {
|
||||
tokens, err := antonSectionTokens(document, block.open)
|
||||
if err != nil {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern key: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
if len(tokens) != 2 {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern key values=%d want=2",
|
||||
ErrLukeCardSetDocumentInvalid, len(tokens))
|
||||
}
|
||||
dungeonID, err := antonPositiveUint32Token(tokens[0], "pattern dungeon")
|
||||
if err != nil {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern dungeon: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
index, err := antonIntToken(tokens[1], "pattern index")
|
||||
if err != nil {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern index: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
if index < 0 {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern index=%d want_nonnegative", ErrLukeCardSetDocumentInvalid, index)
|
||||
}
|
||||
pattern := LukeCardPattern{DungeonID: dungeonID, Index: index}
|
||||
seen := map[string]bool{}
|
||||
for sectionIndex := block.open + 1; sectionIndex < block.close; sectionIndex++ {
|
||||
name := antonSectionName(document.Sections[sectionIndex].Name)
|
||||
switch name {
|
||||
case "image", "card pos", "tooltip text":
|
||||
case "/card pos":
|
||||
// The closing tag of [CARD POS]; it carries no value.
|
||||
continue
|
||||
default:
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern dungeon=%d index=%d unexpected section=%q",
|
||||
ErrLukeCardSetDocumentInvalid, dungeonID, index, document.Sections[sectionIndex].Name)
|
||||
}
|
||||
if seen[name] {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern dungeon=%d index=%d duplicate section=%q",
|
||||
ErrLukeCardSetDocumentInvalid, dungeonID, index, name)
|
||||
}
|
||||
seen[name] = true
|
||||
sectionTokens, tokenErr := antonSectionTokens(document, sectionIndex)
|
||||
if tokenErr != nil {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern %s: %v", ErrLukeCardSetDocumentInvalid, name, tokenErr)
|
||||
}
|
||||
switch name {
|
||||
case "image":
|
||||
path, indices, imageErr := lukeCardSetImage(sectionTokens, "pattern image")
|
||||
if imageErr != nil {
|
||||
return LukeCardPattern{}, imageErr
|
||||
}
|
||||
pattern.ImagePath = path
|
||||
pattern.ImageIndices = indices
|
||||
case "card pos":
|
||||
positions, positionErr := lukeCardSetPositions(sectionTokens)
|
||||
if positionErr != nil {
|
||||
return LukeCardPattern{}, positionErr
|
||||
}
|
||||
pattern.CardPositions = positions
|
||||
case "tooltip text":
|
||||
text, textErr := lukeCardSetTooltip(sectionTokens)
|
||||
if textErr != nil {
|
||||
return LukeCardPattern{}, textErr
|
||||
}
|
||||
pattern.Tooltip = text
|
||||
}
|
||||
}
|
||||
if pattern.ImagePath == "" {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern dungeon=%d index=%d has no [IMAGE]",
|
||||
ErrLukeCardSetDocumentInvalid, dungeonID, index)
|
||||
}
|
||||
if len(pattern.CardPositions) == 0 {
|
||||
return LukeCardPattern{}, fmt.Errorf("%w: pattern dungeon=%d index=%d has no [CARD POS]",
|
||||
ErrLukeCardSetDocumentInvalid, dungeonID, index)
|
||||
}
|
||||
return pattern, nil
|
||||
}
|
||||
|
||||
// parseLukeCard reads one `[CARD] <monsterID>` block. Its key is a single
|
||||
// number, which is why the card table cannot be keyed like the 8-byte pattern
|
||||
// table (see the 1181 spec, §7.3).
|
||||
func parseLukeCard(document *dnfpvf.Document, block antonSectionBlock) (LukeCard, error) {
|
||||
tokens, err := antonSectionTokens(document, block.open)
|
||||
if err != nil {
|
||||
return LukeCard{}, fmt.Errorf("%w: card key: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
return LukeCard{}, fmt.Errorf("%w: card key values=%d want=1", ErrLukeCardSetDocumentInvalid, len(tokens))
|
||||
}
|
||||
monsterID, err := antonPositiveUint32Token(tokens[0], "card monster")
|
||||
if err != nil {
|
||||
return LukeCard{}, fmt.Errorf("%w: card monster: %v", ErrLukeCardSetDocumentInvalid, err)
|
||||
}
|
||||
card := LukeCard{MonsterID: monsterID}
|
||||
for sectionIndex := block.open + 1; sectionIndex < block.close; sectionIndex++ {
|
||||
name := antonSectionName(document.Sections[sectionIndex].Name)
|
||||
if name != "image" && name != "tooltip text" {
|
||||
return LukeCard{}, fmt.Errorf("%w: card monster=%d unexpected section=%q",
|
||||
ErrLukeCardSetDocumentInvalid, monsterID, document.Sections[sectionIndex].Name)
|
||||
}
|
||||
sectionTokens, tokenErr := antonSectionTokens(document, sectionIndex)
|
||||
if tokenErr != nil {
|
||||
return LukeCard{}, fmt.Errorf("%w: card %s: %v", ErrLukeCardSetDocumentInvalid, name, tokenErr)
|
||||
}
|
||||
if name == "image" {
|
||||
if card.ImagePath != "" {
|
||||
return LukeCard{}, fmt.Errorf("%w: card monster=%d duplicate [IMAGE]", ErrLukeCardSetDocumentInvalid, monsterID)
|
||||
}
|
||||
path, indices, imageErr := lukeCardSetImage(sectionTokens, "card image")
|
||||
if imageErr != nil {
|
||||
return LukeCard{}, imageErr
|
||||
}
|
||||
// A card names exactly one frame; the two-number form belongs to the
|
||||
// patterns, whose [IMAGE] holds the buff icon pair.
|
||||
if len(indices) != 1 {
|
||||
return LukeCard{}, fmt.Errorf("%w: card monster=%d image indices=%d want=1",
|
||||
ErrLukeCardSetDocumentInvalid, monsterID, len(indices))
|
||||
}
|
||||
card.ImagePath = path
|
||||
card.ImageIndex = indices[0]
|
||||
continue
|
||||
}
|
||||
text, textErr := lukeCardSetTooltip(sectionTokens)
|
||||
if textErr != nil {
|
||||
return LukeCard{}, textErr
|
||||
}
|
||||
card.Tooltip = text
|
||||
}
|
||||
if card.ImagePath == "" {
|
||||
return LukeCard{}, fmt.Errorf("%w: card monster=%d has no [IMAGE]", ErrLukeCardSetDocumentInvalid, monsterID)
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// lukeCardSetImage reads `[IMAGE]`: one path token followed by a VARIABLE number
|
||||
// of image indices. `5054 *` names one index, `5055 *` / `5056 *` name two
|
||||
// (`2 3`, `4 5`, ...), so the indices must not be read as a fixed pair.
|
||||
func lukeCardSetImage(tokens []dnfpvf.Token, field string) (string, []int, error) {
|
||||
if len(tokens) < 2 {
|
||||
return "", nil, fmt.Errorf("%w: %s values=%d want>=2", ErrLukeCardSetDocumentInvalid, field, len(tokens))
|
||||
}
|
||||
if tokens[0].Kind != dnfpvf.TokenString {
|
||||
return "", nil, fmt.Errorf("%w: %s path kind=%q want=string",
|
||||
ErrLukeCardSetDocumentInvalid, field, tokens[0].Kind)
|
||||
}
|
||||
path := tokens[0].Value
|
||||
if path == "" {
|
||||
return "", nil, fmt.Errorf("%w: %s empty path", ErrLukeCardSetDocumentInvalid, field)
|
||||
}
|
||||
indices := make([]int, 0, len(tokens)-1)
|
||||
for _, token := range tokens[1:] {
|
||||
index, err := antonIntToken(token, field)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %s: %v", ErrLukeCardSetDocumentInvalid, field, err)
|
||||
}
|
||||
if index < 0 {
|
||||
return "", nil, fmt.Errorf("%w: %s index=%d want_nonnegative", ErrLukeCardSetDocumentInvalid, field, index)
|
||||
}
|
||||
indices = append(indices, index)
|
||||
}
|
||||
return path, indices, nil
|
||||
}
|
||||
|
||||
// lukeCardSetPositions reads `[CARD POS]`: 2n values, n (x, y) pairs. Half-step
|
||||
// coordinates arrive as float tokens, whole ones as int tokens, so both kinds
|
||||
// are accepted and both are required to be non-negative.
|
||||
func lukeCardSetPositions(tokens []dnfpvf.Token) ([]LukeCardPosition, error) {
|
||||
if len(tokens) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty [CARD POS]", ErrLukeCardSetDocumentInvalid)
|
||||
}
|
||||
if len(tokens)%2 != 0 {
|
||||
return nil, fmt.Errorf("%w: [CARD POS] values=%d want_even", ErrLukeCardSetDocumentInvalid, len(tokens))
|
||||
}
|
||||
positions := make([]LukeCardPosition, 0, len(tokens)/2)
|
||||
for index := 0; index < len(tokens); index += 2 {
|
||||
x, err := lukeCardSetCoordinate(tokens[index])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
y, err := lukeCardSetCoordinate(tokens[index+1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
positions = append(positions, LukeCardPosition{X: x, Y: y})
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
func lukeCardSetCoordinate(token dnfpvf.Token) (float64, error) {
|
||||
var value float64
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
value = float64(token.Int)
|
||||
case dnfpvf.TokenFloat:
|
||||
value = token.Float
|
||||
default:
|
||||
return 0, fmt.Errorf("%w: [CARD POS] value=%q kind=%q want=number",
|
||||
ErrLukeCardSetDocumentInvalid, token.Raw, token.Kind)
|
||||
}
|
||||
if value < 0 {
|
||||
return 0, fmt.Errorf("%w: [CARD POS] value=%v want_nonnegative", ErrLukeCardSetDocumentInvalid, value)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// lukeCardSetTooltip reads `[TOOLTIP TEXT]`, which is exactly one quoted string.
|
||||
func lukeCardSetTooltip(tokens []dnfpvf.Token) (string, error) {
|
||||
if len(tokens) != 1 {
|
||||
return "", fmt.Errorf("%w: [TOOLTIP TEXT] values=%d want=1", ErrLukeCardSetDocumentInvalid, len(tokens))
|
||||
}
|
||||
if tokens[0].Kind != dnfpvf.TokenString {
|
||||
return "", fmt.Errorf("%w: [TOOLTIP TEXT] kind=%q want=string", ErrLukeCardSetDocumentInvalid, tokens[0].Kind)
|
||||
}
|
||||
return tokens[0].Value, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// realLukeCardSetPVFPath mirrors the reward-catalog smoke switch
|
||||
// (DNF_LUKE_REWARD_REAL_PVF_SMOKE): an explicit path wins, otherwise the shipped
|
||||
// archive beside the repository is used. Unlike the un-gated luke_pvf_test.go
|
||||
// sibling, an explicitly configured path that cannot be opened is a FAILURE, not
|
||||
// a skip: pointing this test at a PVF and silently skipping would hide a broken
|
||||
// table.
|
||||
func realLukeCardSetPVFPath() (string, bool) {
|
||||
if configured := strings.TrimSpace(os.Getenv("DNF_LUKE_REAL_PVF_SMOKE")); configured != "" {
|
||||
return configured, true
|
||||
}
|
||||
return filepath.Clean(filepath.Join("..", "..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf")), false
|
||||
}
|
||||
|
||||
func openRealLukeCardSetPVF(t *testing.T) *platformpvf.Archive {
|
||||
t.Helper()
|
||||
pvfPath, configured := realLukeCardSetPVFPath()
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
if configured {
|
||||
t.Fatalf("DNF_LUKE_REAL_PVF_SMOKE=%q unavailable: %v", pvfPath, err)
|
||||
}
|
||||
t.Skipf("runtime Script.pvf unavailable: %v", err)
|
||||
}
|
||||
return archive
|
||||
}
|
||||
|
||||
// TestRealScriptPVFLukeCardSetTables pins every value the 1181 card display is
|
||||
// built from, read out of etc/raid/luke/LukeCardSet.etc in the shipped
|
||||
// Script.pvf. The [PATTERN] keys, the seven [CARD POS] pairs per pattern, the
|
||||
// variable-length [IMAGE] index list and the 22 major arcana are all asserted
|
||||
// here rather than assumed.
|
||||
func TestRealScriptPVFLukeCardSetTables(t *testing.T) {
|
||||
archive := openRealLukeCardSetPVF(t)
|
||||
config, err := LoadLukeCardSet(archive)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLukeCardSet error = %v", err)
|
||||
}
|
||||
|
||||
// 8 [PATTERN] blocks; 5054 x2, 5055 x4, 5056 x2.
|
||||
if len(config.Patterns) != 8 {
|
||||
t.Fatalf("patterns=%d want 8", len(config.Patterns))
|
||||
}
|
||||
patternKeys := make(map[uint32][]int, 3)
|
||||
for _, pattern := range config.Patterns {
|
||||
patternKeys[pattern.DungeonID] = append(patternKeys[pattern.DungeonID], pattern.Index)
|
||||
}
|
||||
wantKeys := map[uint32][]int{5054: {0, 1}, 5055: {0, 1, 2, 3}, 5056: {0, 1}}
|
||||
if !reflect.DeepEqual(patternKeys, wantKeys) {
|
||||
t.Fatalf("[PATTERN] keys=%v want %v", patternKeys, wantKeys)
|
||||
}
|
||||
for _, dungeonID := range []uint32{5054, 5055, 5056} {
|
||||
if got := config.PatternIndices(dungeonID); !reflect.DeepEqual(got, wantKeys[dungeonID]) {
|
||||
t.Fatalf("PatternIndices(%d)=%v want %v", dungeonID, got, wantKeys[dungeonID])
|
||||
}
|
||||
}
|
||||
|
||||
// Every pattern carries exactly 7 (x, y) pairs = 14 numbers.
|
||||
for _, pattern := range config.Patterns {
|
||||
if len(pattern.CardPositions) != 7 {
|
||||
t.Fatalf("pattern %d %d card positions=%d want 7",
|
||||
pattern.DungeonID, pattern.Index, len(pattern.CardPositions))
|
||||
}
|
||||
if pattern.ImagePath == "" || len(pattern.ImageIndices) == 0 {
|
||||
t.Fatalf("pattern %d %d image=%q indices=%v",
|
||||
pattern.DungeonID, pattern.Index, pattern.ImagePath, pattern.ImageIndices)
|
||||
}
|
||||
if pattern.Tooltip == "" {
|
||||
t.Fatalf("pattern %d %d has no tooltip", pattern.DungeonID, pattern.Index)
|
||||
}
|
||||
}
|
||||
|
||||
// [IMAGE] is variable length: `5054 *` names one buff icon, `5055 *` / `5056 *`
|
||||
// name two (`2 3`, `4 5`, ...). A fixed-pair reader would mis-parse all six.
|
||||
wantImages := map[[2]int][]int{
|
||||
{5054, 0}: {0},
|
||||
{5054, 1}: {1},
|
||||
{5055, 0}: {2, 3},
|
||||
{5056, 0}: {10},
|
||||
{5056, 1}: {11},
|
||||
}
|
||||
for key, want := range wantImages {
|
||||
pattern, found := config.Pattern(uint32(key[0]), key[1])
|
||||
if !found {
|
||||
t.Fatalf("pattern %d %d missing", key[0], key[1])
|
||||
}
|
||||
if !reflect.DeepEqual(pattern.ImageIndices, want) {
|
||||
t.Fatalf("pattern %d %d image indices=%v want %v",
|
||||
key[0], key[1], pattern.ImageIndices, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The first pattern is `5054 0`: 属性值弱化系统 with [CARD POS]
|
||||
// `0 0 1 0 2 0 3 0 0.50 1 1 2 2 2` -- half-step coordinates arrive as floats.
|
||||
first, found := config.Pattern(5054, 0)
|
||||
if !found {
|
||||
t.Fatal("pattern 5054 0 missing")
|
||||
}
|
||||
if first.Tooltip != "属性值弱化系统" {
|
||||
t.Fatalf("pattern 5054 0 tooltip=%q", first.Tooltip)
|
||||
}
|
||||
wantPositions := []LukeCardPosition{
|
||||
{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 2, Y: 0}, {X: 3, Y: 0},
|
||||
{X: 0.5, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 2},
|
||||
}
|
||||
if !reflect.DeepEqual(first.CardPositions, wantPositions) {
|
||||
t.Fatalf("pattern 5054 0 card positions=%+v want %+v", first.CardPositions, wantPositions)
|
||||
}
|
||||
if first.ImagePath != `Interface2\raid\luke\luke_card_system\card_buff_icon.img` {
|
||||
t.Fatalf("pattern 5054 0 image path=%q", first.ImagePath)
|
||||
}
|
||||
|
||||
// 22 major arcana, keyed by monster id, in source order (arcana 0..21).
|
||||
if len(config.Cards) != 22 {
|
||||
t.Fatalf("cards=%d want 22", len(config.Cards))
|
||||
}
|
||||
wantMonsters := []uint32{
|
||||
59171, 59098, 59873, 59096, 56065, 59874, 56699, 57155, 59278, 56166, 56673,
|
||||
59875, 56066, 56674, 56067, 56068, 56063, 56700, 57156, 59872, 59099, 59095,
|
||||
}
|
||||
if got := config.CardMonsterIDs(); !reflect.DeepEqual(got, wantMonsters) {
|
||||
t.Fatalf("card monster ids=%v want %v", got, wantMonsters)
|
||||
}
|
||||
for index, monsterID := range wantMonsters {
|
||||
if !config.HasCard(monsterID) {
|
||||
t.Fatalf("HasCard(%d) = false", monsterID)
|
||||
}
|
||||
if config.Cards[index].MonsterID != monsterID {
|
||||
t.Fatalf("card %d monster=%d want %d", index, config.Cards[index].MonsterID, monsterID)
|
||||
}
|
||||
if config.Cards[index].ImageIndex != index {
|
||||
t.Fatalf("card %d image index=%d want %d", index, config.Cards[index].ImageIndex, index)
|
||||
}
|
||||
if config.Cards[index].ImagePath != `Interface2\raid\luke\luke_card_system\tarotcard.img` {
|
||||
t.Fatalf("card %d image path=%q", index, config.Cards[index].ImagePath)
|
||||
}
|
||||
}
|
||||
// The arcana the official announcement names must be the ones the PVF holds:
|
||||
// 00 愚者_红色小丑 and 21 世界_噩梦之内尔贝.
|
||||
if !strings.Contains(config.Cards[0].Tooltip, "愚者") {
|
||||
t.Fatalf("arcana 0 tooltip=%q", config.Cards[0].Tooltip)
|
||||
}
|
||||
if !strings.Contains(config.Cards[21].Tooltip, "世界") {
|
||||
t.Fatalf("arcana 21 tooltip=%q", config.Cards[21].Tooltip)
|
||||
}
|
||||
if config.HasCard(0) || config.HasCard(1) {
|
||||
t.Fatal("HasCard accepted a non-arcana key")
|
||||
}
|
||||
|
||||
// The values this test pins, printed so a run leaves the real numbers in its
|
||||
// output rather than only a PASS.
|
||||
for _, pattern := range config.Patterns {
|
||||
t.Logf("pattern dungeon=%d index=%d image_indices=%v card_positions=%d tooltip=%q",
|
||||
pattern.DungeonID, pattern.Index, pattern.ImageIndices, len(pattern.CardPositions), pattern.Tooltip)
|
||||
}
|
||||
t.Logf("cards=%d first_five_monsters=%v", len(config.Cards), config.CardMonsterIDs()[:5])
|
||||
}
|
||||
|
||||
// TestLukeCardSetRejectsShapesTheClientCouldNotDraw keeps the parser fail-closed:
|
||||
// every one of these documents would make the 1181 body describe something the
|
||||
// client cannot resolve, and the caller must not send a half-described packet.
|
||||
func TestLukeCardSetRejectsShapesTheClientCouldNotDraw(t *testing.T) {
|
||||
header := "[PATTERN]\n5054 0 \n[IMAGE]\n`a.img` 0 \n[CARD POS]\n0 0 1 0 \n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n"
|
||||
card := "[CARD]\n59171 \n[IMAGE]\n`c.img` 0 \n[TOOLTIP TEXT]\n`x` \n[/CARD]\n"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{"no pattern", card},
|
||||
{"no card", header},
|
||||
{"odd card pos", "[PATTERN]\n5054 0 \n[IMAGE]\n`a.img` 0 \n[CARD POS]\n0 0 1 \n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n" + card},
|
||||
{"empty card pos", "[PATTERN]\n5054 0 \n[IMAGE]\n`a.img` 0 \n[CARD POS]\n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n" + card},
|
||||
{"pattern without image", "[PATTERN]\n5054 0 \n[CARD POS]\n0 0 \n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n" + card},
|
||||
{"pattern key too wide", "[PATTERN]\n5054 0 7 \n[IMAGE]\n`a.img` 0 \n[CARD POS]\n0 0 \n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n" + card},
|
||||
{"duplicate pattern key", header + header + card},
|
||||
{"duplicate card monster", header + card + card},
|
||||
{"card with two image indices", header + "[CARD]\n59171 \n[IMAGE]\n`c.img` 0 1 \n[TOOLTIP TEXT]\n`x` \n[/CARD]\n"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
document, err := dnfpvf.Parse(LukeCardSetPVFPath, testCase.text)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: parse error = %v", testCase.name, err)
|
||||
}
|
||||
if _, err := ParseLukeCardSet(document); err == nil {
|
||||
t.Fatalf("%s: ParseLukeCardSet accepted the document", testCase.name)
|
||||
}
|
||||
}
|
||||
// The same shape with a bad coordinate is rejected too.
|
||||
document, err := dnfpvf.Parse(LukeCardSetPVFPath, "[PATTERN]\n5054 0 \n[IMAGE]\n`a.img` 0 \n[CARD POS]\n0 -1 \n[/CARD POS]\n[TOOLTIP TEXT]\n`t` \n[/PATTERN]\n"+card)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error = %v", err)
|
||||
}
|
||||
if _, err := ParseLukeCardSet(document); err == nil {
|
||||
t.Fatal("a negative [CARD POS] coordinate was accepted")
|
||||
}
|
||||
// A document for another path is refused, so a mis-wired caller is loud.
|
||||
other, err := dnfpvf.Parse(LukePVFPath, header+card)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error = %v", err)
|
||||
}
|
||||
if _, err := ParseLukeCardSet(other); err == nil {
|
||||
t.Fatal("ParseLukeCardSet accepted luke.etc")
|
||||
}
|
||||
if _, err := ParseLukeCardSet(nil); err == nil {
|
||||
t.Fatal("ParseLukeCardSet accepted a nil document")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const LukePVFPath = "etc/raid/luke/luke.etc"
|
||||
|
||||
var (
|
||||
ErrLukePVFSourceRequired = errors.New("luke raid PVF source is required")
|
||||
ErrLukePVFDocumentInvalid = errors.New("luke raid PVF document is invalid")
|
||||
ErrLukePVFRouteInvalid = errors.New("luke single raid PVF route is invalid")
|
||||
)
|
||||
|
||||
// LukeSanctumObjectCount is one [DEFAULT OBJECT COUNT] entry: how many
|
||||
// destroyed generators a phase-one sanctum's counter holds (the 「破坏目标 x/5」
|
||||
// denominator), and the gate they belong to. It is not a placement count: the
|
||||
// birth sanctum's maze 0 alone places ten 64001 generators. Section is the native marker name ("birth sanctum" and its two
|
||||
// siblings); the gate's own [DESTROY OBJECT INFO] names the same group with an
|
||||
// " object" suffix.
|
||||
type LukeSanctumObjectCount struct {
|
||||
Section string
|
||||
Count int
|
||||
DungeonID uint32
|
||||
}
|
||||
|
||||
// LukePhaseOneObjectDestroy is one row of the global [1PHASE OBJ DESTROY]
|
||||
// table: a tier and the inclusive range of phase-one generators destroyed
|
||||
// (all three sanctums together) that earns it. The current PVF reads
|
||||
// `0 0 5 1 6 10 2 11 15`: 0-5 destroyed is tier 0, 6-10 tier 1, 11-15 tier 2.
|
||||
//
|
||||
// The tier is [ENERGY CONTROL OPEN] (symbol 133), which luke.etc only ever
|
||||
// reads: its three `5063 clear` triggers pick the energy control room's
|
||||
// respawn timer by it (0 → 45 s, 1 → 90 s, 2 → 120 s). Group keeps the field's
|
||||
// original name; it holds the tier.
|
||||
type LukePhaseOneObjectDestroy struct {
|
||||
Group int
|
||||
Minimum int
|
||||
Maximum int
|
||||
}
|
||||
|
||||
// EnergyControlTier maps a phase-one destroyed-generator total onto its
|
||||
// [1PHASE OBJ DESTROY] tier. ok is false when no row covers the total.
|
||||
func (c *LukeConfig) EnergyControlTier(destroyed int) (tier int, ok bool) {
|
||||
if c == nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, row := range c.PhaseOneObjectDestroy {
|
||||
if destroyed >= row.Minimum && destroyed <= row.Maximum {
|
||||
return row.Group, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// SanctumObjectCount is the [DEFAULT OBJECT COUNT] of one phase-one sanctum:
|
||||
// how many destroyed generators that sanctum's counter can hold (5).
|
||||
func (c *LukeConfig) SanctumObjectCount(dungeonID uint32) (int, bool) {
|
||||
if c == nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, entry := range c.SanctumObjectCounts {
|
||||
if entry.DungeonID == dungeonID {
|
||||
return entry.Count, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// LukeConfig owns the current PVF's Luke raid configuration. The phase,
|
||||
// ticket and reward shapes are shared with the native raid grammar parsed by
|
||||
// AntonConfig; Luke-specific route identities remain explicit here.
|
||||
type LukeConfig struct {
|
||||
Raid *AntonConfig
|
||||
DailyAccountEnterLimit int
|
||||
// SanctumTimerCount is the global [SANCTUM TIMER COUNT]. It matches the
|
||||
// per-sanctum object count the client draws as the phase-one energy bar.
|
||||
SanctumTimerCount int
|
||||
// SanctumObjectCounts is [DEFAULT OBJECT COUNT] in source order.
|
||||
SanctumObjectCounts []LukeSanctumObjectCount
|
||||
// PhaseOneObjectDestroy is [1PHASE OBJ DESTROY] in source order.
|
||||
PhaseOneObjectDestroy []LukePhaseOneObjectDestroy
|
||||
// CardSetMapInfo is the raw [CARD SET MAP INFO] value list. The client
|
||||
// renders the phase-one card puzzle from symbols 130..132
|
||||
// ([CARD SET PATTERN A/B/C] in etc/raid/luke/luke.etc.symbol), but how this
|
||||
// list is grouped onto them is not proven by the PVF, so it stays raw.
|
||||
CardSetMapInfo []int
|
||||
LightRoute []uint32
|
||||
DarkRoute []uint32
|
||||
}
|
||||
|
||||
func LoadLukeConfig(source dnfpvf.Source) (*LukeConfig, error) {
|
||||
if source == nil {
|
||||
return nil, ErrLukePVFSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(LukePVFPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read luke raid PVF %q: %w", LukePVFPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(LukePVFPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse luke raid PVF %q: %w", LukePVFPath, err)
|
||||
}
|
||||
return ParseLukeConfig(document)
|
||||
}
|
||||
|
||||
func ParseLukeConfig(document *dnfpvf.Document) (*LukeConfig, error) {
|
||||
if document == nil || antonPathKey(document.Path) != antonPathKey(LukePVFPath) {
|
||||
path := ""
|
||||
if document != nil {
|
||||
path = document.Path
|
||||
}
|
||||
return nil, fmt.Errorf("%w: path=%q want=%q", ErrLukePVFDocumentInvalid, path, LukePVFPath)
|
||||
}
|
||||
phaseBlocks, err := antonFindBlocks(document, "phase", 0, len(document.Sections))
|
||||
if err != nil || len(phaseBlocks) == 0 {
|
||||
return nil, fmt.Errorf("%w: ordinary phase boundary: %v", ErrLukePVFDocumentInvalid, err)
|
||||
}
|
||||
globalEnd := phaseBlocks[0].open
|
||||
dailyLimit, err := antonReadPositiveIntSection(document, "daily account enter limit", 0, globalEnd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: daily account enter limit: %v", ErrLukePVFDocumentInvalid, err)
|
||||
}
|
||||
sanctumTimerCount, err := antonReadPositiveIntSection(document, "sanctum timer count", 0, globalEnd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: sanctum timer count: %v", ErrLukePVFDocumentInvalid, err)
|
||||
}
|
||||
sanctumObjectCounts, err := parseLukeSanctumObjectCounts(document, 0, globalEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phaseOneObjectDestroy, err := parseLukePhaseOneObjectDestroy(document, 0, globalEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardSetMapInfo, err := parseLukeCardSetMapInfo(document, 0, globalEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The two raid documents use the same native section grammar. Parse a
|
||||
// path-adjusted document through the one strict grammar owner, then restore
|
||||
// Luke identity in this wrapper instead of maintaining a divergent parser.
|
||||
// The shared grammar now reads Luke's per-gate [LOCKOUT TYPE] and
|
||||
// [DESTROY OBJECT INFO] itself, so no section is hidden from it any more;
|
||||
// only the tokens are copied, because Luke deliberately uses zero for the
|
||||
// ordinary-phase minimum clear count and Anton rejects that value. The Luke
|
||||
// values are preserved, a parser-safe sentinel is substituted in the private
|
||||
// copy, and the native values are restored on the resulting config.
|
||||
shared := *document
|
||||
shared.Path = AntonPVFPath
|
||||
shared.Tokens = append([]dnfpvf.Token(nil), document.Tokens...)
|
||||
minimumClearCounts := make([]int, 0, len(phaseBlocks))
|
||||
for phaseIndex, block := range phaseBlocks {
|
||||
minimumIndex, findErr := antonUniqueSection(document, "minimal dungeon clear count", block.open+1, block.close)
|
||||
if findErr != nil {
|
||||
return nil, fmt.Errorf("%w: phase=%d minimum clear count: %v", ErrLukePVFDocumentInvalid, phaseIndex+1, findErr)
|
||||
}
|
||||
minimum, readErr := antonNonNegativeIntAt(document, minimumIndex, "minimal dungeon clear count")
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%w: phase=%d minimum clear count: %v", ErrLukePVFDocumentInvalid, phaseIndex+1, readErr)
|
||||
}
|
||||
minimumClearCounts = append(minimumClearCounts, minimum)
|
||||
if minimum == 0 {
|
||||
section := document.Sections[minimumIndex]
|
||||
if section.End != section.Start+1 {
|
||||
return nil, fmt.Errorf("%w: phase=%d minimum clear count shape", ErrLukePVFDocumentInvalid, phaseIndex+1)
|
||||
}
|
||||
shared.Tokens[section.Start].Int = 1
|
||||
shared.Tokens[section.Start].Raw = "1"
|
||||
shared.Tokens[section.Start].Value = "1"
|
||||
}
|
||||
}
|
||||
base, err := ParseAntonConfig(&shared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: shared raid grammar: %v", ErrLukePVFDocumentInvalid, err)
|
||||
}
|
||||
for index, minimum := range minimumClearCounts {
|
||||
base.Phases[index].MinimalDungeonClearCount = minimum
|
||||
}
|
||||
if base.SingleMode == nil {
|
||||
return nil, fmt.Errorf("%w: single phase missing", ErrLukePVFRouteInvalid)
|
||||
}
|
||||
|
||||
config := &LukeConfig{
|
||||
Raid: base,
|
||||
DailyAccountEnterLimit: dailyLimit,
|
||||
SanctumTimerCount: sanctumTimerCount,
|
||||
SanctumObjectCounts: sanctumObjectCounts,
|
||||
PhaseOneObjectDestroy: phaseOneObjectDestroy,
|
||||
CardSetMapInfo: cardSetMapInfo,
|
||||
LightRoute: []uint32{5323, 5324, 5325, 5326, 5327, 5328},
|
||||
DarkRoute: []uint32{5329, 5330, 5331, 5332, 5333, 5334},
|
||||
}
|
||||
if err := validateLukeSingleRoutes(base.SingleMode.Phase.Dungeons, config.LightRoute, config.DarkRoute); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateLukeSanctumObjects(base, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// parseLukeSanctumObjectCounts reads the global [DEFAULT OBJECT COUNT] block.
|
||||
// Each entry is a marker section carrying the object count and the gate id.
|
||||
func parseLukeSanctumObjectCounts(document *dnfpvf.Document, start, end int) ([]LukeSanctumObjectCount, error) {
|
||||
blocks, err := antonFindBlocks(document, "default object count", start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: default object count: %v", ErrLukePVFDocumentInvalid, err)
|
||||
}
|
||||
if len(blocks) != 1 {
|
||||
return nil, fmt.Errorf("%w: default object count blocks=%d want=1", ErrLukePVFDocumentInvalid, len(blocks))
|
||||
}
|
||||
block := blocks[0]
|
||||
entries := make([]LukeSanctumObjectCount, 0, block.close-block.open-1)
|
||||
for index := block.open + 1; index < block.close; index++ {
|
||||
tokens, tokenErr := antonSectionTokens(document, index)
|
||||
if tokenErr != nil {
|
||||
return nil, fmt.Errorf("%w: default object count: %v", ErrLukePVFDocumentInvalid, tokenErr)
|
||||
}
|
||||
if len(tokens) != 2 {
|
||||
return nil, fmt.Errorf("%w: default object count section=%q values=%d want=2",
|
||||
ErrLukePVFDocumentInvalid, document.Sections[index].Name, len(tokens))
|
||||
}
|
||||
count, countErr := antonPositiveIntToken(tokens[0], "default object count")
|
||||
if countErr != nil {
|
||||
return nil, fmt.Errorf("%w: default object count: %v", ErrLukePVFDocumentInvalid, countErr)
|
||||
}
|
||||
dungeonID, dungeonErr := antonPositiveUint32Token(tokens[1], "default object count dungeon")
|
||||
if dungeonErr != nil {
|
||||
return nil, fmt.Errorf("%w: default object count: %v", ErrLukePVFDocumentInvalid, dungeonErr)
|
||||
}
|
||||
entries = append(entries, LukeSanctumObjectCount{
|
||||
Section: antonSectionName(document.Sections[index].Name),
|
||||
Count: count,
|
||||
DungeonID: dungeonID,
|
||||
})
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("%w: default object count has no entries", ErrLukePVFDocumentInvalid)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// parseLukePhaseOneObjectDestroy reads the global [1PHASE OBJ DESTROY] table.
|
||||
// The values sit on the opening section as consecutive group/min/max triples.
|
||||
func parseLukePhaseOneObjectDestroy(document *dnfpvf.Document, start, end int) ([]LukePhaseOneObjectDestroy, error) {
|
||||
values, err := lukeBlockIntValues(document, "1phase obj destroy", start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("%w: 1phase obj destroy values=%d want_multiple_of=3", ErrLukePVFDocumentInvalid, len(values))
|
||||
}
|
||||
rows := make([]LukePhaseOneObjectDestroy, 0, len(values)/3)
|
||||
for index := 0; index < len(values); index += 3 {
|
||||
row := LukePhaseOneObjectDestroy{Group: values[index], Minimum: values[index+1], Maximum: values[index+2]}
|
||||
if row.Maximum < row.Minimum {
|
||||
return nil, fmt.Errorf("%w: 1phase obj destroy group=%d range=%d..%d",
|
||||
ErrLukePVFDocumentInvalid, row.Group, row.Minimum, row.Maximum)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("%w: 1phase obj destroy has no rows", ErrLukePVFDocumentInvalid)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func parseLukeCardSetMapInfo(document *dnfpvf.Document, start, end int) ([]int, error) {
|
||||
values, err := lukeBlockIntValues(document, "card set map info", start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("%w: card set map info has no values", ErrLukePVFDocumentInvalid)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// lukeBlockIntValues reads one single-section block whose values sit on the
|
||||
// opening section, with an empty closing tag and nothing nested between them.
|
||||
func lukeBlockIntValues(document *dnfpvf.Document, name string, start, end int) ([]int, error) {
|
||||
blocks, err := antonFindBlocks(document, name, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrLukePVFDocumentInvalid, name, err)
|
||||
}
|
||||
if len(blocks) != 1 {
|
||||
return nil, fmt.Errorf("%w: %s blocks=%d want=1", ErrLukePVFDocumentInvalid, name, len(blocks))
|
||||
}
|
||||
block := blocks[0]
|
||||
if block.close != block.open+1 {
|
||||
return nil, fmt.Errorf("%w: %s unexpected nested sections", ErrLukePVFDocumentInvalid, name)
|
||||
}
|
||||
if err := antonRequireEmptySection(document, block.close); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrLukePVFDocumentInvalid, name, err)
|
||||
}
|
||||
tokens, err := antonSectionTokens(document, block.open)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrLukePVFDocumentInvalid, name, err)
|
||||
}
|
||||
values := make([]int, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
value, valueErr := antonIntToken(token, name)
|
||||
if valueErr != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %v", ErrLukePVFDocumentInvalid, name, valueErr)
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("%w: %s value=%d want_nonnegative", ErrLukePVFDocumentInvalid, name, value)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// validateLukeSanctumObjects proves the phase-one object wiring is internally
|
||||
// consistent: every [DEFAULT OBJECT COUNT] entry must name a phase-one gate
|
||||
// that declares the matching group under its own [DESTROY OBJECT INFO], and
|
||||
// the count must agree with the global [SANCTUM TIMER COUNT]. The two sides
|
||||
// spell the same group with and without an " object" suffix.
|
||||
func validateLukeSanctumObjects(base *AntonConfig, config *LukeConfig) error {
|
||||
if base == nil || len(base.Phases) == 0 {
|
||||
return fmt.Errorf("%w: sanctum objects: no ordinary phase", ErrLukePVFDocumentInvalid)
|
||||
}
|
||||
groups := make(map[uint32]map[string]struct{}, len(base.Phases[0].Dungeons))
|
||||
for _, dungeon := range base.Phases[0].Dungeons {
|
||||
declared := make(map[string]struct{}, len(dungeon.DestroyObjects))
|
||||
for _, object := range dungeon.DestroyObjects {
|
||||
declared[object] = struct{}{}
|
||||
}
|
||||
groups[dungeon.Index] = declared
|
||||
}
|
||||
for _, entry := range config.SanctumObjectCounts {
|
||||
declared, ok := groups[entry.DungeonID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: sanctum object dungeon=%d not in phase one", ErrLukePVFDocumentInvalid, entry.DungeonID)
|
||||
}
|
||||
want := entry.Section + " object"
|
||||
if _, found := declared[want]; !found {
|
||||
return fmt.Errorf("%w: sanctum object dungeon=%d missing group=%q",
|
||||
ErrLukePVFDocumentInvalid, entry.DungeonID, want)
|
||||
}
|
||||
if entry.Count != config.SanctumTimerCount {
|
||||
return fmt.Errorf("%w: sanctum object dungeon=%d count=%d sanctum_timer_count=%d",
|
||||
ErrLukePVFDocumentInvalid, entry.DungeonID, entry.Count, config.SanctumTimerCount)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLukeSingleRoutes(dungeons []AntonDungeonInfo, routes ...[]uint32) error {
|
||||
want := make(map[uint32]struct{})
|
||||
for _, route := range routes {
|
||||
if len(route) == 0 {
|
||||
return fmt.Errorf("%w: empty route", ErrLukePVFRouteInvalid)
|
||||
}
|
||||
for _, dungeonID := range route {
|
||||
if dungeonID == 0 {
|
||||
return fmt.Errorf("%w: zero dungeon id", ErrLukePVFRouteInvalid)
|
||||
}
|
||||
if _, duplicate := want[dungeonID]; duplicate {
|
||||
return fmt.Errorf("%w: duplicate dungeon=%d", ErrLukePVFRouteInvalid, dungeonID)
|
||||
}
|
||||
want[dungeonID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(dungeons) != len(want) {
|
||||
return fmt.Errorf("%w: dungeons=%d want=%d", ErrLukePVFRouteInvalid, len(dungeons), len(want))
|
||||
}
|
||||
for _, dungeon := range dungeons {
|
||||
if _, ok := want[dungeon.Index]; !ok {
|
||||
return fmt.Errorf("%w: unexpected dungeon=%d", ErrLukePVFRouteInvalid, dungeon.Index)
|
||||
}
|
||||
if strings.TrimSpace(string(dungeon.InitState)) != string(AntonDungeonHide) || dungeon.HP <= 0 {
|
||||
return fmt.Errorf("%w: dungeon=%d state=%q hp=%d", ErrLukePVFRouteInvalid, dungeon.Index, dungeon.InitState, dungeon.HP)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealScriptPVFLukeSingleRoutes(t *testing.T) {
|
||||
pvfPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Skipf("runtime Script.pvf unavailable: %v", err)
|
||||
}
|
||||
config, err := LoadLukeConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLukeConfig error = %v", err)
|
||||
}
|
||||
if config.Raid == nil || config.Raid.SingleMode == nil {
|
||||
t.Fatal("Luke single config missing")
|
||||
}
|
||||
if config.DailyAccountEnterLimit != 10 || config.Raid.StartDelaySeconds != 3 ||
|
||||
config.Raid.EntryCost.ItemID != 10159649 || config.Raid.EntryCost.Count != 1 {
|
||||
t.Fatalf("Luke global config = %+v", config)
|
||||
}
|
||||
if got := config.LightRoute; len(got) != 6 || got[0] != 5323 || got[5] != 5328 {
|
||||
t.Fatalf("light route = %v", got)
|
||||
}
|
||||
if got := config.DarkRoute; len(got) != 6 || got[0] != 5329 || got[5] != 5334 {
|
||||
t.Fatalf("dark route = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealScriptPVFLukePhaseMechanismTables pins the phase-one object tables and
|
||||
// the per-gate route classifiers the two-phase runtime owner needs. Every value
|
||||
// here is read from etc/raid/luke/luke.etc.
|
||||
func TestRealScriptPVFLukePhaseMechanismTables(t *testing.T) {
|
||||
pvfPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
|
||||
archive, err := platformpvf.Open(pvfPath)
|
||||
if err != nil {
|
||||
t.Skipf("runtime Script.pvf unavailable: %v", err)
|
||||
}
|
||||
config, err := LoadLukeConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLukeConfig error = %v", err)
|
||||
}
|
||||
if config.Raid == nil || len(config.Raid.Phases) != 2 {
|
||||
t.Fatalf("Luke ordinary phases = %+v", config.Raid)
|
||||
}
|
||||
if config.Raid.WeeklyEnterCount != [2]int{20, 2} {
|
||||
t.Fatalf("weekly enter count = %v want [20 2]", config.Raid.WeeklyEnterCount)
|
||||
}
|
||||
if config.SanctumTimerCount != 5 {
|
||||
t.Fatalf("sanctum timer count = %d want 5", config.SanctumTimerCount)
|
||||
}
|
||||
wantSanctums := []LukeSanctumObjectCount{
|
||||
{Section: "birth sanctum", Count: 5, DungeonID: 5054},
|
||||
{Section: "extinction sanctum", Count: 5, DungeonID: 5055},
|
||||
{Section: "spread sanctum", Count: 5, DungeonID: 5056},
|
||||
}
|
||||
if !reflect.DeepEqual(config.SanctumObjectCounts, wantSanctums) {
|
||||
t.Fatalf("sanctum object counts = %+v want %+v", config.SanctumObjectCounts, wantSanctums)
|
||||
}
|
||||
wantDestroy := []LukePhaseOneObjectDestroy{
|
||||
{Group: 0, Minimum: 0, Maximum: 5},
|
||||
{Group: 1, Minimum: 6, Maximum: 10},
|
||||
{Group: 2, Minimum: 11, Maximum: 15},
|
||||
}
|
||||
if !reflect.DeepEqual(config.PhaseOneObjectDestroy, wantDestroy) {
|
||||
t.Fatalf("1phase obj destroy = %+v want %+v", config.PhaseOneObjectDestroy, wantDestroy)
|
||||
}
|
||||
wantCards := []int{0, 0, 0, 3, 3, 3, 6, 6, 6, 9, 9, 9, 12, 12, 12}
|
||||
if !reflect.DeepEqual(config.CardSetMapInfo, wantCards) {
|
||||
t.Fatalf("card set map info = %v want %v", config.CardSetMapInfo, wantCards)
|
||||
}
|
||||
|
||||
// Phase one declares no revive-coin cap and no minimum clear count; every
|
||||
// gate is a one-party sanctum carrying its own destructible object group.
|
||||
phaseOne := config.Raid.Phases[0]
|
||||
if phaseOne.CoinLimit != 0 || phaseOne.MinimalDungeonClearCount != 0 {
|
||||
t.Fatalf("phase one coin_limit=%d minimum_clear=%d", phaseOne.CoinLimit, phaseOne.MinimalDungeonClearCount)
|
||||
}
|
||||
wantObjects := map[uint32]string{
|
||||
5054: "birth sanctum object",
|
||||
5055: "extinction sanctum object",
|
||||
5056: "spread sanctum object",
|
||||
}
|
||||
if len(phaseOne.Dungeons) != len(wantObjects) {
|
||||
t.Fatalf("phase one dungeons = %+v", phaseOne.Dungeons)
|
||||
}
|
||||
for _, dungeon := range phaseOne.Dungeons {
|
||||
want, ok := wantObjects[dungeon.Index]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected phase one dungeon=%d", dungeon.Index)
|
||||
}
|
||||
if dungeon.PartyMax != 1 || dungeon.HP != 3 || dungeon.LockoutType != [2]int{0, 0} {
|
||||
t.Fatalf("phase one dungeon=%d party_max=%d hp=%d lockout=%v",
|
||||
dungeon.Index, dungeon.PartyMax, dungeon.HP, dungeon.LockoutType)
|
||||
}
|
||||
if !reflect.DeepEqual(dungeon.DestroyObjects, []string{want}) {
|
||||
t.Fatalf("phase one dungeon=%d destroy objects=%v want=[%q]", dungeon.Index, dungeon.DestroyObjects, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase two caps revive coins and tags every gate with its route: 4 on the
|
||||
// light chain, 5 on the dark one, 0 on the two shared rooms (energy control
|
||||
// 5063 and energy save 5066).
|
||||
phaseTwo := config.Raid.Phases[1]
|
||||
if phaseTwo.CoinLimit != 20 || phaseTwo.MinimalDungeonClearCount != 2 {
|
||||
t.Fatalf("phase two coin_limit=%d minimum_clear=%d", phaseTwo.CoinLimit, phaseTwo.MinimalDungeonClearCount)
|
||||
}
|
||||
wantLockout := map[uint32]int{
|
||||
5311: 4, 5057: 4, 5059: 4, 5064: 4, 5061: 4,
|
||||
5313: 5, 5058: 5, 5060: 5, 5065: 5, 5062: 5,
|
||||
5063: 0, 5066: 0,
|
||||
}
|
||||
if len(phaseTwo.Dungeons) != len(wantLockout) {
|
||||
t.Fatalf("phase two dungeons = %d want %d", len(phaseTwo.Dungeons), len(wantLockout))
|
||||
}
|
||||
for _, dungeon := range phaseTwo.Dungeons {
|
||||
want, ok := wantLockout[dungeon.Index]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected phase two dungeon=%d", dungeon.Index)
|
||||
}
|
||||
if dungeon.LockoutType != [2]int{want, 0} {
|
||||
t.Fatalf("phase two dungeon=%d lockout=%v want [%d 0]", dungeon.Index, dungeon.LockoutType, want)
|
||||
}
|
||||
if len(dungeon.DestroyObjects) != 0 {
|
||||
t.Fatalf("phase two dungeon=%d unexpected destroy objects=%v", dungeon.Index, dungeon.DestroyObjects)
|
||||
}
|
||||
}
|
||||
|
||||
// The rank table is shared by both phases and the single-mode phase.
|
||||
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},
|
||||
}
|
||||
for index, phase := range []AntonPhase{phaseOne, phaseTwo, config.Raid.SingleMode.Phase} {
|
||||
if !reflect.DeepEqual(phase.RankConditions, wantRank) {
|
||||
t.Fatalf("phase %d rank conditions = %+v want %+v", index, phase.RankConditions, wantRank)
|
||||
}
|
||||
}
|
||||
|
||||
// All three [MINIMAL DUNGEON CLEAR COUNT] values the reward gate can read
|
||||
// (dnfbridge runtimeRaidPhaseMinimalClearRequirement) are pinned here, because
|
||||
// they are three different answers and one of them is easy to get wrong:
|
||||
//
|
||||
// - phase one 0 and phase two 2 are the official reward condition
|
||||
// 「1阶段:无条件 / 2阶段:通关2个以上的地下城」;
|
||||
// - the single-mode block omits the section entirely, so the shared parser
|
||||
// defaults it to 1 (anton_pvf.go:503) instead of inheriting team phase
|
||||
// one's 0. If single mode ever started reading that 0, the raid would pay
|
||||
// its whole roster without anyone clearing a gate.
|
||||
if got := config.Raid.SingleMode.Phase.MinimalDungeonClearCount; got != 1 {
|
||||
t.Fatalf("single-mode minimum clear count = %d want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// Luke phase-one raid monster list.
|
||||
//
|
||||
// etc/raid/luke/luke.etc.buff [RAID MONSTER LIST] is the table that decides which
|
||||
// named monster and which boss each maze of each raid dungeon actually spawns:
|
||||
//
|
||||
// [RAID MONSTER LIST]
|
||||
// [DUNGEON]
|
||||
// 5054
|
||||
// [MAZE]
|
||||
// 0
|
||||
// [BOSS]
|
||||
// 56063
|
||||
// [/MAZE]
|
||||
// [MAZE]
|
||||
// 1
|
||||
// [NAMED]
|
||||
// 56674
|
||||
// [BOSS]
|
||||
// 56700
|
||||
// [/MAZE]
|
||||
// ...
|
||||
// [/DUNGEON]
|
||||
// ...
|
||||
// [/RAID MONSTER LIST]
|
||||
//
|
||||
// One dungeon owns fifteen [MAZE] blocks. They form five groups of three
|
||||
// consecutive mazes -- the same five groups luke.etc [CARD SET MAP INFO] names by
|
||||
// their first maze (0 0 0 3 3 3 6 6 6 9 9 9 12 12 12) -- and the third maze of
|
||||
// every group carries the sanctum's own boss (5054: mazes 2/5/8/11/14 all end in
|
||||
// 56673), while the first two carry the group's named monster and the shared mid
|
||||
// boss. See raid_luke_sanctum_maze.go for how the day's group is drawn.
|
||||
//
|
||||
// MonsterIDs keeps the source order of one maze, which is [NAMED] first and then
|
||||
// [BOSS], because the tarot card slots are filled in that order (see
|
||||
// runtimeRaidLukeTarotCardsFromMazeMonsters).
|
||||
const LukeRaidMonsterListPVFPath = "etc/raid/luke/luke.etc.buff"
|
||||
|
||||
var (
|
||||
// ErrLukeRaidMonsterListSourceRequired is returned for a nil PVF source.
|
||||
ErrLukeRaidMonsterListSourceRequired = errors.New("luke raid monster list PVF source is required")
|
||||
// ErrLukeRaidMonsterListDocumentInvalid is returned for any document that is
|
||||
// not a usable [RAID MONSTER LIST].
|
||||
ErrLukeRaidMonsterListDocumentInvalid = errors.New("luke raid monster list document is invalid")
|
||||
)
|
||||
|
||||
// LukeRaidMonsterListMaze is one [MAZE] block. Index is the maze index inside its
|
||||
// dungeon's .dgn, and MonsterIDs are the monsters that maze spawns, in the order
|
||||
// the document lists them.
|
||||
type LukeRaidMonsterListMaze struct {
|
||||
Index int
|
||||
MonsterIDs []uint32
|
||||
}
|
||||
|
||||
// LukeRaidMonsterListDungeon is one [DUNGEON] block.
|
||||
type LukeRaidMonsterListDungeon struct {
|
||||
DungeonID uint32
|
||||
Mazes []LukeRaidMonsterListMaze
|
||||
}
|
||||
|
||||
// LukeRaidMonsterListConfig owns etc/raid/luke/luke.etc.buff [RAID MONSTER LIST].
|
||||
type LukeRaidMonsterListConfig struct {
|
||||
Dungeons []LukeRaidMonsterListDungeon
|
||||
}
|
||||
|
||||
// LoadLukeRaidMonsterList reads and parses the raid monster list out of one PVF
|
||||
// source.
|
||||
func LoadLukeRaidMonsterList(source dnfpvf.Source) (*LukeRaidMonsterListConfig, error) {
|
||||
if source == nil {
|
||||
return nil, ErrLukeRaidMonsterListSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(LukeRaidMonsterListPVFPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read luke raid monster list PVF %q: %w", LukeRaidMonsterListPVFPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(LukeRaidMonsterListPVFPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse luke raid monster list PVF %q: %w", LukeRaidMonsterListPVFPath, err)
|
||||
}
|
||||
return ParseLukeRaidMonsterList(document)
|
||||
}
|
||||
|
||||
// ParseLukeRaidMonsterList reads [RAID MONSTER LIST] out of an already parsed
|
||||
// document. The path must be the buff document, so that luke.etc cannot be
|
||||
// mistaken for it.
|
||||
func ParseLukeRaidMonsterList(document *dnfpvf.Document) (*LukeRaidMonsterListConfig, error) {
|
||||
if document == nil || antonPathKey(document.Path) != antonPathKey(LukeRaidMonsterListPVFPath) {
|
||||
path := ""
|
||||
if document != nil {
|
||||
path = document.Path
|
||||
}
|
||||
return nil, fmt.Errorf("%w: path=%q want=%q",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, path, LukeRaidMonsterListPVFPath)
|
||||
}
|
||||
lists, err := antonFindBlocks(document, "raid monster list", 0, len(document.Sections))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: raid monster list block: %v", ErrLukeRaidMonsterListDocumentInvalid, err)
|
||||
}
|
||||
if len(lists) != 1 {
|
||||
return nil, fmt.Errorf("%w: [RAID MONSTER LIST] blocks=%d want=1",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, len(lists))
|
||||
}
|
||||
dungeonBlocks, err := antonFindBlocks(document, "dungeon", lists[0].open+1, lists[0].close)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: dungeon blocks: %v", ErrLukeRaidMonsterListDocumentInvalid, err)
|
||||
}
|
||||
if len(dungeonBlocks) == 0 {
|
||||
return nil, fmt.Errorf("%w: no [DUNGEON] block", ErrLukeRaidMonsterListDocumentInvalid)
|
||||
}
|
||||
config := &LukeRaidMonsterListConfig{
|
||||
Dungeons: make([]LukeRaidMonsterListDungeon, 0, len(dungeonBlocks)),
|
||||
}
|
||||
seenDungeons := make(map[uint32]bool, len(dungeonBlocks))
|
||||
for _, block := range dungeonBlocks {
|
||||
dungeon, parseErr := parseLukeRaidMonsterListDungeon(document, block)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if seenDungeons[dungeon.DungeonID] {
|
||||
return nil, fmt.Errorf("%w: duplicate [DUNGEON] %d",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeon.DungeonID)
|
||||
}
|
||||
seenDungeons[dungeon.DungeonID] = true
|
||||
config.Dungeons = append(config.Dungeons, dungeon)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Dungeon returns one dungeon's maze table.
|
||||
func (c *LukeRaidMonsterListConfig) Dungeon(dungeonID uint32) (LukeRaidMonsterListDungeon, bool) {
|
||||
if c == nil {
|
||||
return LukeRaidMonsterListDungeon{}, false
|
||||
}
|
||||
for _, dungeon := range c.Dungeons {
|
||||
if dungeon.DungeonID == dungeonID {
|
||||
return dungeon, true
|
||||
}
|
||||
}
|
||||
return LukeRaidMonsterListDungeon{}, false
|
||||
}
|
||||
|
||||
// MazeCount is how many [MAZE] blocks one dungeon declares: the dungeon's maze
|
||||
// count, which is what the day's group start has to stay inside of. ok is false
|
||||
// for a dungeon the table does not describe.
|
||||
func (c *LukeRaidMonsterListConfig) MazeCount(dungeonID uint32) (int, bool) {
|
||||
dungeon, ok := c.Dungeon(dungeonID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return len(dungeon.Mazes), true
|
||||
}
|
||||
|
||||
// MazeMonsters returns one maze's monsters in source order. ok is false when the
|
||||
// table does not describe that dungeon, and a described maze without monsters
|
||||
// returns an empty slice and true -- "this maze spawns nothing", which is not the
|
||||
// same answer as "this table does not know the maze".
|
||||
func (c *LukeRaidMonsterListConfig) MazeMonsters(dungeonID uint32, mazeIndex int) ([]uint32, bool) {
|
||||
dungeon, ok := c.Dungeon(dungeonID)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for _, maze := range dungeon.Mazes {
|
||||
if maze.Index == mazeIndex {
|
||||
return maze.MonsterIDs, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parseLukeRaidMonsterListDungeon(
|
||||
document *dnfpvf.Document,
|
||||
block antonSectionBlock,
|
||||
) (LukeRaidMonsterListDungeon, error) {
|
||||
tokens, err := antonSectionTokens(document, block.open)
|
||||
if err != nil {
|
||||
return LukeRaidMonsterListDungeon{}, fmt.Errorf("%w: dungeon key: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
return LukeRaidMonsterListDungeon{}, fmt.Errorf("%w: dungeon key values=%d want=1",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, len(tokens))
|
||||
}
|
||||
dungeonID, err := antonPositiveUint32Token(tokens[0], "raid monster list dungeon")
|
||||
if err != nil {
|
||||
return LukeRaidMonsterListDungeon{}, fmt.Errorf("%w: dungeon: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, err)
|
||||
}
|
||||
mazeBlocks, err := antonFindBlocks(document, "maze", block.open+1, block.close)
|
||||
if err != nil {
|
||||
return LukeRaidMonsterListDungeon{}, fmt.Errorf("%w: dungeon=%d maze blocks: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, err)
|
||||
}
|
||||
// A [DUNGEON] block without [MAZE] is a real shape: the table also covers the
|
||||
// phase-two dungeons, and 5310 ships one that only lists [PASSIVE] objects.
|
||||
// That is "this dungeon has no maze list", not a broken document.
|
||||
dungeon := LukeRaidMonsterListDungeon{
|
||||
DungeonID: dungeonID,
|
||||
Mazes: make([]LukeRaidMonsterListMaze, 0, len(mazeBlocks)),
|
||||
}
|
||||
if len(mazeBlocks) == 0 {
|
||||
return dungeon, nil
|
||||
}
|
||||
seen := make(map[int]bool, len(mazeBlocks))
|
||||
for _, mazeBlock := range mazeBlocks {
|
||||
maze, parseErr := parseLukeRaidMonsterListMaze(document, dungeonID, mazeBlock)
|
||||
if parseErr != nil {
|
||||
return LukeRaidMonsterListDungeon{}, parseErr
|
||||
}
|
||||
if seen[maze.Index] {
|
||||
return LukeRaidMonsterListDungeon{}, fmt.Errorf("%w: dungeon=%d duplicate [MAZE] %d",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, maze.Index)
|
||||
}
|
||||
seen[maze.Index] = true
|
||||
dungeon.Mazes = append(dungeon.Mazes, maze)
|
||||
}
|
||||
return dungeon, nil
|
||||
}
|
||||
|
||||
// parseLukeRaidMonsterListMaze reads one `[MAZE] <index>` block. Only [NAMED],
|
||||
// [BOSS] and [PASSIVE] may appear inside; [PASSIVE] is deliberately not a
|
||||
// monster -- it names a map passive object, and the [CARD] table that the tarot
|
||||
// display is keyed by only ever holds monsters.
|
||||
func parseLukeRaidMonsterListMaze(
|
||||
document *dnfpvf.Document,
|
||||
dungeonID uint32,
|
||||
block antonSectionBlock,
|
||||
) (LukeRaidMonsterListMaze, error) {
|
||||
tokens, err := antonSectionTokens(document, block.open)
|
||||
if err != nil {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze key: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze key values=%d want=1",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, len(tokens))
|
||||
}
|
||||
index, err := antonIntToken(tokens[0], "raid monster list maze index")
|
||||
if err != nil {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, err)
|
||||
}
|
||||
if index < 0 {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze index=%d want_nonnegative",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, index)
|
||||
}
|
||||
maze := LukeRaidMonsterListMaze{Index: index, MonsterIDs: make([]uint32, 0, 2)}
|
||||
for sectionIndex := block.open + 1; sectionIndex < block.close; sectionIndex++ {
|
||||
name := antonSectionName(document.Sections[sectionIndex].Name)
|
||||
switch name {
|
||||
case "named", "boss":
|
||||
case "passive":
|
||||
// Not a monster: a map passive object, never a [CARD] key.
|
||||
continue
|
||||
default:
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf(
|
||||
"%w: dungeon=%d maze=%d unexpected section=%q",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, index,
|
||||
document.Sections[sectionIndex].Name)
|
||||
}
|
||||
sectionTokens, tokenErr := antonSectionTokens(document, sectionIndex)
|
||||
if tokenErr != nil {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze=%d %s: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, index, name, tokenErr)
|
||||
}
|
||||
if len(sectionTokens) != 1 {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze=%d %s values=%d want=1",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, index, name, len(sectionTokens))
|
||||
}
|
||||
monsterID, monsterErr := antonPositiveUint32Token(sectionTokens[0], "raid monster list monster")
|
||||
if monsterErr != nil {
|
||||
return LukeRaidMonsterListMaze{}, fmt.Errorf("%w: dungeon=%d maze=%d %s: %v",
|
||||
ErrLukeRaidMonsterListDocumentInvalid, dungeonID, index, name, monsterErr)
|
||||
}
|
||||
maze.MonsterIDs = append(maze.MonsterIDs, monsterID)
|
||||
}
|
||||
return maze, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// TestParseLukeRaidMonsterListReadsMazesInSourceOrder pins the table the 1181
|
||||
// card display is derived from: one [DUNGEON] per raid dungeon, one [MAZE] per
|
||||
// maze index, monsters in the order the document lists them, [PASSIVE] skipped
|
||||
// (it names a map object, not a monster) and a dungeon without any [MAZE] kept
|
||||
// as an empty list rather than rejected (the shipped 5310 is exactly that).
|
||||
func TestParseLukeRaidMonsterListReadsMazesInSourceOrder(t *testing.T) {
|
||||
document, err := dnfpvf.Parse(LukeRaidMonsterListPVFPath, "[RAID MONSTER LIST]\n"+
|
||||
"[DUNGEON]\n5054\n"+
|
||||
"[MAZE]\n0\n[BOSS]\n56063\n[/MAZE]\n"+
|
||||
"[MAZE]\n1\n[NAMED]\n56674\n[BOSS]\n56700\n[/MAZE]\n"+
|
||||
"[MAZE]\n2\n[PASSIVE]\n64062\n[NAMED]\n59873\n[BOSS]\n56673\n[/MAZE]\n"+
|
||||
"[/DUNGEON]\n"+
|
||||
"[DUNGEON]\n5310\n[PASSIVE]\n64062\n[/DUNGEON]\n"+
|
||||
"[/RAID MONSTER LIST]\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := ParseLukeRaidMonsterList(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.Dungeons) != 2 {
|
||||
t.Fatalf("dungeons=%+v", config.Dungeons)
|
||||
}
|
||||
dungeon, ok := config.Dungeon(5054)
|
||||
if !ok || dungeon.DungeonID != 5054 {
|
||||
t.Fatalf("dungeon 5054=%+v ok=%t", dungeon, ok)
|
||||
}
|
||||
if count, ok := config.MazeCount(5054); !ok || count != 3 {
|
||||
t.Fatalf("5054 maze count=%d ok=%t want 3", count, ok)
|
||||
}
|
||||
want := [][]uint32{{56063}, {56674, 56700}, {59873, 56673}}
|
||||
for index, monsters := range want {
|
||||
got, ok := config.MazeMonsters(5054, index)
|
||||
if !ok || !reflect.DeepEqual(got, monsters) {
|
||||
t.Fatalf("maze %d monsters=%v ok=%t want %v", index, got, ok, monsters)
|
||||
}
|
||||
}
|
||||
// [PASSIVE] must not have been taken for a monster.
|
||||
for _, maze := range dungeon.Mazes {
|
||||
for _, monsterID := range maze.MonsterIDs {
|
||||
if monsterID == 64062 {
|
||||
t.Fatalf("maze %d kept the [PASSIVE] object as a monster: %v", maze.Index, maze.MonsterIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
empty, ok := config.Dungeon(5310)
|
||||
if !ok || len(empty.Mazes) != 0 {
|
||||
t.Fatalf("dungeon 5310=%+v ok=%t want no mazes", empty, ok)
|
||||
}
|
||||
if _, ok := config.MazeMonsters(5310, 0); ok {
|
||||
t.Fatal("a dungeon without [MAZE] answered a maze lookup")
|
||||
}
|
||||
if _, ok := config.Dungeon(9999); ok {
|
||||
t.Fatal("an unknown dungeon was found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseLukeRaidMonsterListRejectsOtherDocuments keeps luke.etc and the tarot
|
||||
// tables from being read as the monster list.
|
||||
func TestParseLukeRaidMonsterListRejectsOtherDocuments(t *testing.T) {
|
||||
if _, err := ParseLukeRaidMonsterList(nil); err == nil {
|
||||
t.Fatal("a nil document was accepted")
|
||||
}
|
||||
document, err := dnfpvf.Parse(LukePVFPath, "[RAID MONSTER LIST]\n[DUNGEON]\n5054\n[MAZE]\n0\n[BOSS]\n56063\n[/MAZE]\n[/DUNGEON]\n[/RAID MONSTER LIST]\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseLukeRaidMonsterList(document); err == nil {
|
||||
t.Fatal("luke.etc was accepted as the raid monster list")
|
||||
}
|
||||
if _, err := LoadLukeRaidMonsterList(nil); err == nil {
|
||||
t.Fatal("a nil source was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 本文件按 NoPack 公共频道 raid S2C handler 的读取顺序构造固定包体。
|
||||
package raid
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
// RaidOtherChannelUserinfoMsgID 对应 sub_1D57390: u32 count + repeat u32 key。
|
||||
RaidOtherChannelUserinfoMsgID uint16 = 819
|
||||
// RaidOtherChannelRequestJoinResultMsgID 对应 sub_1D573E0: u32,u32。
|
||||
RaidOtherChannelRequestJoinResultMsgID uint16 = 820
|
||||
// RaidOtherChannelResponseJoinMsgID 对应 sub_CC2480: u8,u8。
|
||||
RaidOtherChannelResponseJoinMsgID uint16 = 821
|
||||
// RaidOtherChannelListPageMsgID 对应 sub_1D4C060: raw[20], rows12, rows13。
|
||||
RaidOtherChannelListPageMsgID uint16 = 0x374
|
||||
// RaidPublicListMsgID 对应 sub_1D7AA10。它才是“寻找攻坚队”窗口的
|
||||
// 攻坚队对象快照,不是 0x374 的跨频道通用页容器。
|
||||
RaidPublicListMsgID uint16 = 0x02f1
|
||||
// RaidPublicListPageSize 来自 sub_1B4B520 的 33 * page 请求偏移。
|
||||
RaidPublicListPageSize = 33
|
||||
)
|
||||
|
||||
var errRaidPublicListPageTooLarge = errors.New("raid public list page exceeds 33 objects")
|
||||
|
||||
// PublicRaidListPage is the exact class-0/op753 snapshot consumed by
|
||||
// sub_1D7AA10: total matching rows, returned row offset, row count, then full
|
||||
// sub_17B8910 raid objects followed by their one-byte member counts.
|
||||
type PublicRaidListPage struct {
|
||||
TotalCount uint32
|
||||
Offset uint32
|
||||
Objects []RaidObjectMode0
|
||||
}
|
||||
|
||||
// BuildPublicRaidListBody builds the native “寻找攻坚队” object page.
|
||||
func BuildPublicRaidListBody(page PublicRaidListPage) ([]byte, error) {
|
||||
if len(page.Objects) > RaidPublicListPageSize {
|
||||
return nil, errRaidPublicListPageTooLarge
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeUint32(page.TotalCount)
|
||||
writer.writeUint32(page.Offset)
|
||||
writer.writeUint32(uint32(len(page.Objects)))
|
||||
for _, object := range page.Objects {
|
||||
if len(object.Members) > MaxAttackPartyMembers {
|
||||
return nil, errRaidMemberTooMany
|
||||
}
|
||||
if err := writeRaidObjectBase(&writer, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.writeByte(byte(len(object.Members)))
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// OtherChannelListPage 是 S2C 0x374 的保守构造输入。
|
||||
//
|
||||
// MCP 20260705 复核:sub_1D4C060 开头读取 raw[20],随后读取
|
||||
// u32 count + repeat raw[12],后半段再读取 u32 count + repeat raw[13]。
|
||||
// 当前只把它作为 831 公共频道列表请求之后的候选列表页回包构造器,
|
||||
// 不在 handler 中自动发送。
|
||||
//
|
||||
// Rows12 会被完整拷贝进步长 12 的容器。
|
||||
// Rows13 是 0x183/0x34C/0x8F 同族 72 字节列表项的压缩格式:
|
||||
// +0 u32, +4 u16, +6 u16, +8 u8, +9 u8, +10 u8, +11 u8;
|
||||
// +12 暂未见直接使用。和扩展格式相比,0x374 不携带写入 item+0x24 的额外 u16,
|
||||
// 因此客户端把 item+0x24 固定为 0。
|
||||
type OtherChannelListPage struct {
|
||||
Header [20]byte
|
||||
Rows12 [][12]byte
|
||||
Rows13 [][13]byte
|
||||
}
|
||||
|
||||
// BuildOtherChannelListPageBody 构造 S2C 0x374 公共频道/攻坚列表页候选包体。
|
||||
func BuildOtherChannelListPageBody(page OtherChannelListPage) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeRaw(page.Header[:])
|
||||
writeCheckUserRows12(&writer, page.Rows12)
|
||||
writer.writeUint32(uint32(len(page.Rows13)))
|
||||
for _, row := range page.Rows13 {
|
||||
writer.writeRaw(row[:])
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildOtherChannelUserinfoBody 构造 S2C 819 公共频道用户 key 列表。
|
||||
func BuildOtherChannelUserinfoBody(keys []uint32) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(uint32(len(keys)))
|
||||
for _, key := range keys {
|
||||
writer.writeUint32(key)
|
||||
}
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildOtherChannelRequestJoinResultBody 构造 S2C 820 的两个 u32 字段。
|
||||
//
|
||||
// MCP 20260705 复核:sub_1D573E0 读两个 u32 后调用
|
||||
// sub_1C5B070(type=3, valueA, valueB)。valueA 必须匹配客户端对象 +552,
|
||||
// valueB 是累加量,达到对象 +556 阈值后才置完成标志。它不是通用成功 ACK。
|
||||
func BuildOtherChannelRequestJoinResultBody(valueA uint32, valueB uint32) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeUint32(valueA)
|
||||
writer.writeUint32(valueB)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
// BuildOtherChannelResponseJoinBody 构造 S2C 821 响应加入结果。
|
||||
//
|
||||
// MCP 20260705 复核:sub_CC2480 把 flag 写到 UI 对象 292 的 +4(bool),
|
||||
// 把 resultOrSlot 写到 +8,然后触发 UI 610 刷新;实际语义仍由 owner 决定。
|
||||
func BuildOtherChannelResponseJoinBody(resultOrSlot byte, flag byte) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(resultOrSlot)
|
||||
writer.writeByte(flag)
|
||||
return writer.bytes()
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// 本文件解析攻坚队管理类 C2S 请求体。
|
||||
package raid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// InfoRequest 是 C2S 0x298/664 创建攻坚队和 0x29E/670 修改攻坚队信息的共用包体。
|
||||
// MCP 证据:sub_1B49240 先写 u8,再写 dstr 名称,最后写 u8。
|
||||
type InfoRequest struct {
|
||||
RouteOrRaidType byte
|
||||
NameBytes []byte
|
||||
TailFlag byte
|
||||
}
|
||||
|
||||
// RequestRaidInfoRequest 是 C2S 0x54F/1359 的请求体:u8 mode + u32 raid_key。
|
||||
//
|
||||
// 证据:客户端组包点 sub_17B1A90 依次 push 0x54F、一个字节 2、以及 raid_key,
|
||||
// 实测正文 `01 01 00 17 00` 的后四字节 0x00170001 正是当前攻坚队的 key。
|
||||
// (旧注释曾把后两个字节当成频道号,那是因为 raid_key 的高 16 位恰好就是频道。)
|
||||
// S2C 1359 的当前 EXE handler sub_1D3A150 不读取正文,只把本地对象的 +0xE1E 置 1。
|
||||
type RequestRaidInfoRequest struct {
|
||||
Mode byte
|
||||
RaidKey uint32
|
||||
Raw [5]byte
|
||||
}
|
||||
|
||||
// LeaveRequest 是 C2S 0x299/665 离开攻坚队包体。
|
||||
// MCP 证据:sub_17B1E30/sub_324F850 只写一个 u16。
|
||||
type LeaveRequest struct {
|
||||
RaidOrMemberKey uint16
|
||||
}
|
||||
|
||||
// RejoinRequest 是 C2S 0x29C/668 重新加入攻坚链路的客户端回包。
|
||||
// MCP 证据:sub_1D638D0 的触发分支只回写一个 u32。
|
||||
type RejoinRequest struct {
|
||||
RaidKey uint32
|
||||
}
|
||||
|
||||
// SetWaitingRequest 是 C2S 0x29B/667 的攻坚等待状态请求。
|
||||
// MCP 证据:sub_1B4C2A0 分支发送 u8 0,再发送 u8 sub_17B1CD0(...)。
|
||||
type SetWaitingRequest struct {
|
||||
Flag byte
|
||||
RouteRaidType byte
|
||||
}
|
||||
|
||||
// EntryCostInfoRequest 是 C2S 0x292/658 的 raid 入场材料/消耗信息请求。
|
||||
// MCP 20260705 证据:sub_17B19C0 发送 658,正文只写 u8(a2 != 0)。
|
||||
type EntryCostInfoRequest struct {
|
||||
Enabled byte
|
||||
}
|
||||
|
||||
// SetSymbolRequest 是 C2S 0x296/662 的 raid 标记设置请求。
|
||||
// MCP 20260705 证据:sub_17B1600 发送 662,正文写 u32,u32,u8;第三字段必须小于 3。
|
||||
type SetSymbolRequest struct {
|
||||
SourceValue uint32
|
||||
TargetValue uint32
|
||||
Symbol byte
|
||||
}
|
||||
|
||||
// ManagerWorkRequest 是 C2S 0x29D/669 的小组编辑请求。
|
||||
type ManagerWorkRequest struct {
|
||||
ActionOrMode uint32
|
||||
MemberCharKey uint32
|
||||
TargetGroup uint32
|
||||
}
|
||||
|
||||
// OtherChannelRequestJoinRequest 是 C2S 0x334/820 的跨频道攻坚申请。
|
||||
// MCP 证据:sub_17B1530 写 u8,u16,u32,u16。
|
||||
type OtherChannelRequestJoinRequest struct {
|
||||
Mode byte
|
||||
TargetKey uint16
|
||||
ClientValue uint32
|
||||
RouteRaidType uint16
|
||||
}
|
||||
|
||||
// MemberChangeStateRequest 是 C2S 0x337/823 的成员状态变化请求。
|
||||
// MCP 证据:sub_17B1C40 只写 u8 state。
|
||||
type MemberChangeStateRequest struct {
|
||||
State byte
|
||||
}
|
||||
|
||||
// UserMoveChannelFailRequest 是 C2S 0x338/824 的跨频道移动失败上报。
|
||||
// MCP 证据:sub_17B17A0 写 u8,u16。
|
||||
type UserMoveChannelFailRequest struct {
|
||||
Mode byte
|
||||
TargetKey uint16
|
||||
}
|
||||
|
||||
// OtherChannelListRequest 是 C2S 0x33F/831 的公共频道攻坚列表请求。
|
||||
// MCP 证据:sub_17B3140 写 u8=1,或 u8=0 + raw[8] + dstr name。
|
||||
type OtherChannelListRequest struct {
|
||||
Mode byte
|
||||
Context [8]byte
|
||||
NameBytes []byte
|
||||
HasContext bool
|
||||
HasNameDstr bool
|
||||
}
|
||||
|
||||
// CheckRaidUserRequest 是 C2S 0x379/889 的攻坚用户检查请求。
|
||||
// MCP 证据:sub_17B1D50 写 u8,u16;S2C 889 是复杂列表,不在解析层伪造成功。
|
||||
type CheckRaidUserRequest struct {
|
||||
Mode byte
|
||||
TargetKey uint16
|
||||
}
|
||||
|
||||
var errRaidInfoBodyShort = errors.New("raid info body too short")
|
||||
var errRaidInfoNameTruncated = errors.New("raid info name dstr truncated")
|
||||
var errRaidRequestInfoBodyShort = errors.New("raid request info body must be five bytes")
|
||||
var errRaidLeaveBodyShort = errors.New("raid leave body too short")
|
||||
var errRaidStartBodyNotEmpty = errors.New("raid start body must be empty")
|
||||
var errRaidRejoinBodyShort = errors.New("raid rejoin body too short")
|
||||
var errRaidSetWaitingBodyShort = errors.New("raid set waiting body too short")
|
||||
var errRaidEntryCostBodyShort = errors.New("raid entry cost info body too short")
|
||||
var errRaidSetSymbolBodyShort = errors.New("raid set symbol body too short")
|
||||
var errRaidSetSymbolInvalid = errors.New("raid set symbol must be less than 3")
|
||||
var errManagerWorkBodyShort = errors.New("raid manager work body too short")
|
||||
var errRaidOtherChannelJoinBodyShort = errors.New("raid other channel request join body too short")
|
||||
var errRaidMemberStateBodyShort = errors.New("raid member change state body too short")
|
||||
var errRaidMoveChannelFailBodyShort = errors.New("raid user move channel fail body too short")
|
||||
var errRaidOtherChannelListBodyShort = errors.New("raid other channel list body too short")
|
||||
var errRaidOtherChannelListNameTruncated = errors.New("raid other channel list name dstr truncated")
|
||||
var errRaidCheckUserBodyShort = errors.New("raid check user body too short")
|
||||
|
||||
// DecodeCreateRaidRequest 按当前 EXE 写包顺序解析创建攻坚队请求。
|
||||
func DecodeCreateRaidRequest(body []byte) (InfoRequest, error) {
|
||||
return decodeInfoRequest(body)
|
||||
}
|
||||
|
||||
// DecodeModifyRaidInfoRequest 按当前 EXE 写包顺序解析修改攻坚队信息请求。
|
||||
func DecodeModifyRaidInfoRequest(body []byte) (InfoRequest, error) {
|
||||
return decodeInfoRequest(body)
|
||||
}
|
||||
|
||||
// DecodeRequestRaidInfoRequest 解析当前客户端发送的 1359 请求。
|
||||
//
|
||||
// 布局 u8 mode + u32 raid_key 来自组包点 sub_17B1A90(push 0x54F / push 2 /
|
||||
// push raid_key)。实测样本的 mode 为 1 或 2:mode 1 是打开攻坚队窗口时的常规
|
||||
// 刷新,mode 2 是客户端刚为某个 key 新建了对象、回头向服务端索取队信息。
|
||||
func DecodeRequestRaidInfoRequest(body []byte) (RequestRaidInfoRequest, error) {
|
||||
if len(body) != 5 {
|
||||
return RequestRaidInfoRequest{}, errRaidRequestInfoBodyShort
|
||||
}
|
||||
var request RequestRaidInfoRequest
|
||||
copy(request.Raw[:], body)
|
||||
request.Mode = body[0]
|
||||
request.RaidKey = binary.LittleEndian.Uint32(body[1:5])
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func decodeInfoRequest(body []byte) (InfoRequest, error) {
|
||||
if len(body) < 6 {
|
||||
return InfoRequest{}, errRaidInfoBodyShort
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(body[1:5]))
|
||||
end := 5 + nameLen
|
||||
if end+1 != len(body) {
|
||||
return InfoRequest{}, errRaidInfoNameTruncated
|
||||
}
|
||||
return InfoRequest{
|
||||
RouteOrRaidType: body[0],
|
||||
NameBytes: append([]byte(nil), body[5:end]...),
|
||||
TailFlag: body[end],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeLeaveRaidRequest parses the exact u16 body written by the current EXE.
|
||||
func DecodeLeaveRaidRequest(body []byte) (LeaveRequest, error) {
|
||||
if len(body) != 2 {
|
||||
return LeaveRequest{}, errRaidLeaveBodyShort
|
||||
}
|
||||
return LeaveRequest{RaidOrMemberKey: binary.LittleEndian.Uint16(body[0:2])}, nil
|
||||
}
|
||||
|
||||
// DecodeStartRaidRequest 校验开始攻坚请求;当前 EXE 没有写任何正文。
|
||||
func DecodeStartRaidRequest(body []byte) error {
|
||||
if len(body) != 0 {
|
||||
return errRaidStartBodyNotEmpty
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecodeRejoinRaidRequest 解析重新加入攻坚请求。
|
||||
func DecodeRejoinRaidRequest(body []byte) (RejoinRequest, error) {
|
||||
if len(body) != 4 {
|
||||
return RejoinRequest{}, errRaidRejoinBodyShort
|
||||
}
|
||||
return RejoinRequest{RaidKey: binary.LittleEndian.Uint32(body[0:4])}, nil
|
||||
}
|
||||
|
||||
// DecodeSetWaitingRequest 解析 667 攻坚等待状态请求。
|
||||
func DecodeSetWaitingRequest(body []byte) (SetWaitingRequest, error) {
|
||||
if len(body) != 2 {
|
||||
return SetWaitingRequest{}, errRaidSetWaitingBodyShort
|
||||
}
|
||||
return SetWaitingRequest{Flag: body[0], RouteRaidType: body[1]}, nil
|
||||
}
|
||||
|
||||
// DecodeEntryCostInfoRequest 解析 658 raid 入场消耗信息请求。
|
||||
func DecodeEntryCostInfoRequest(body []byte) (EntryCostInfoRequest, error) {
|
||||
if len(body) != 1 {
|
||||
return EntryCostInfoRequest{}, errRaidEntryCostBodyShort
|
||||
}
|
||||
return EntryCostInfoRequest{Enabled: body[0]}, nil
|
||||
}
|
||||
|
||||
// DecodeSetSymbolRequest 解析 662 raid 标记设置请求。
|
||||
func DecodeSetSymbolRequest(body []byte) (SetSymbolRequest, error) {
|
||||
if len(body) != 9 {
|
||||
return SetSymbolRequest{}, errRaidSetSymbolBodyShort
|
||||
}
|
||||
if body[8] >= 3 {
|
||||
return SetSymbolRequest{}, errRaidSetSymbolInvalid
|
||||
}
|
||||
return SetSymbolRequest{
|
||||
SourceValue: binary.LittleEndian.Uint32(body[0:4]),
|
||||
TargetValue: binary.LittleEndian.Uint32(body[4:8]),
|
||||
Symbol: body[8],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeManagerWorkRequest 按 NoPack sub_1B4D3D0/sub_1B480E0 的发送顺序解析 669。
|
||||
func DecodeManagerWorkRequest(body []byte) (ManagerWorkRequest, error) {
|
||||
if len(body) != 12 {
|
||||
return ManagerWorkRequest{}, errManagerWorkBodyShort
|
||||
}
|
||||
return ManagerWorkRequest{
|
||||
ActionOrMode: binary.LittleEndian.Uint32(body[0:4]),
|
||||
MemberCharKey: binary.LittleEndian.Uint32(body[4:8]),
|
||||
TargetGroup: binary.LittleEndian.Uint32(body[8:12]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeOtherChannelRequestJoinRequest 解析 820 跨频道攻坚申请请求。
|
||||
func DecodeOtherChannelRequestJoinRequest(body []byte) (OtherChannelRequestJoinRequest, error) {
|
||||
if len(body) != 9 {
|
||||
return OtherChannelRequestJoinRequest{}, errRaidOtherChannelJoinBodyShort
|
||||
}
|
||||
return OtherChannelRequestJoinRequest{
|
||||
Mode: body[0],
|
||||
TargetKey: binary.LittleEndian.Uint16(body[1:3]),
|
||||
ClientValue: binary.LittleEndian.Uint32(body[3:7]),
|
||||
RouteRaidType: binary.LittleEndian.Uint16(body[7:9]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeMemberChangeStateRequest 解析 823 攻坚成员状态变化请求。
|
||||
func DecodeMemberChangeStateRequest(body []byte) (MemberChangeStateRequest, error) {
|
||||
if len(body) != 1 {
|
||||
return MemberChangeStateRequest{}, errRaidMemberStateBodyShort
|
||||
}
|
||||
return MemberChangeStateRequest{State: body[0]}, nil
|
||||
}
|
||||
|
||||
// DecodeUserMoveChannelFailRequest 解析 824 跨频道移动失败上报。
|
||||
func DecodeUserMoveChannelFailRequest(body []byte) (UserMoveChannelFailRequest, error) {
|
||||
if len(body) != 3 {
|
||||
return UserMoveChannelFailRequest{}, errRaidMoveChannelFailBodyShort
|
||||
}
|
||||
return UserMoveChannelFailRequest{Mode: body[0], TargetKey: binary.LittleEndian.Uint16(body[1:3])}, nil
|
||||
}
|
||||
|
||||
// DecodeOtherChannelListRequest 解析 831 公共频道攻坚列表请求。
|
||||
func DecodeOtherChannelListRequest(body []byte) (OtherChannelListRequest, error) {
|
||||
if len(body) < 1 {
|
||||
return OtherChannelListRequest{}, errRaidOtherChannelListBodyShort
|
||||
}
|
||||
out := OtherChannelListRequest{Mode: body[0]}
|
||||
if out.Mode != 0 {
|
||||
if len(body) != 1 {
|
||||
return OtherChannelListRequest{}, errRaidOtherChannelListBodyShort
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if len(body) < 13 {
|
||||
return OtherChannelListRequest{}, errRaidOtherChannelListBodyShort
|
||||
}
|
||||
copy(out.Context[:], body[1:9])
|
||||
out.HasContext = true
|
||||
nameLen := int(binary.LittleEndian.Uint32(body[9:13]))
|
||||
end := 13 + nameLen
|
||||
if end != len(body) {
|
||||
return OtherChannelListRequest{}, errRaidOtherChannelListNameTruncated
|
||||
}
|
||||
out.NameBytes = append([]byte(nil), body[13:end]...)
|
||||
out.HasNameDstr = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DecodeCheckRaidUserRequest 解析 889 攻坚用户检查请求。
|
||||
func DecodeCheckRaidUserRequest(body []byte) (CheckRaidUserRequest, error) {
|
||||
if len(body) != 3 {
|
||||
return CheckRaidUserRequest{}, errRaidCheckUserBodyShort
|
||||
}
|
||||
return CheckRaidUserRequest{Mode: body[0], TargetKey: binary.LittleEndian.Uint16(body[1:3])}, nil
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// Current-client C2S command ids used by the native raid result flow.
|
||||
RaidResultStageRequestMsgID uint16 = 0x0293
|
||||
RaidRewardCardRequestMsgID uint16 = 0x0294
|
||||
RaidDoBehaviorRequestMsgID uint16 = 0x0295
|
||||
RaidMessageRequestMsgID uint16 = 0x0297
|
||||
|
||||
maxRaidResultType byte = 3
|
||||
maxRaidResultPhase byte = 1
|
||||
maxRaidRewardCardMode byte = 2
|
||||
)
|
||||
|
||||
// The raid banner is NOT built here. It is the class-0 notification opcode 119,
|
||||
// sub_1D88DE0, whose body is op12's twin; dnfbridge owns that builder
|
||||
// (buildCurrentRaidAnnouncementBody) next to the chat notification it mirrors.
|
||||
// 0x024C is a different surface entirely and raises no banner.
|
||||
|
||||
var (
|
||||
errRaidResultTypeInvalid = errors.New("raid result type must be between 0 and 3")
|
||||
errRaidResultPhaseInvalid = errors.New("raid result phase must be 0 or 1")
|
||||
errRaidResultStageBodyInvalid = errors.New("raid result stage request body must contain exactly one byte")
|
||||
errRaidResultStageInvalid = errors.New("raid result stage must be 0 or 1")
|
||||
errRaidRewardCardBodyInvalid = errors.New("raid reward card request body must contain exactly two bytes")
|
||||
errRaidRewardCardModeInvalid = errors.New("raid reward card mode must be between 0 and 2")
|
||||
errRaidDoBehaviorBodyInvalid = errors.New("raid behavior request body must contain exactly six bytes")
|
||||
errRaidMessageBodyInvalid = errors.New("raid message request body must contain u8,dstr,u8 exactly")
|
||||
errRaidMessageDSTRTruncated = errors.New("raid message request dstr is truncated")
|
||||
errRaidNormalRewardItemInvalid = errors.New("raid normal item reward requires both a non-zero item id and count")
|
||||
)
|
||||
|
||||
// RaidSettlementResult is the fixed-width 0x0259 raid result body consumed by
|
||||
// the current client.
|
||||
type RaidSettlementResult struct {
|
||||
ResultType byte
|
||||
PhaseIndex byte
|
||||
ClearSeconds uint32
|
||||
DeathCount uint16
|
||||
Score byte
|
||||
Flags byte
|
||||
}
|
||||
|
||||
// RaidSettlementReward is one seven-byte inner row in a 0x0258 member group.
|
||||
// Kind zero is the conservative normal-item form. A fully zero row is the
|
||||
// native empty sentinel.
|
||||
type RaidSettlementReward struct {
|
||||
Kind byte
|
||||
ItemIDOrValue uint32
|
||||
CountOrAux uint16
|
||||
}
|
||||
|
||||
// RaidSettlementMemberRewards is one member/card group in 0x0258.
|
||||
type RaidSettlementMemberRewards struct {
|
||||
MemberKey uint16
|
||||
CardIndex byte
|
||||
Rewards []RaidSettlementReward
|
||||
}
|
||||
|
||||
// RaidSettlementRewards is the complete nested 0x0258 body.
|
||||
type RaidSettlementRewards struct {
|
||||
ResultType byte
|
||||
Members []RaidSettlementMemberRewards
|
||||
}
|
||||
|
||||
// RaidResultStageRequest is the exact one-byte C2S 659 request.
|
||||
type RaidResultStageRequest struct {
|
||||
Stage byte
|
||||
}
|
||||
|
||||
// RaidRewardCardRequest is the exact two-byte C2S 660 request.
|
||||
type RaidRewardCardRequest struct {
|
||||
Mode byte
|
||||
CardIndex byte
|
||||
}
|
||||
|
||||
// RaidDoBehaviorRequest is the exact fixed-width C2S 661 report. The current
|
||||
// client has no corresponding ACK handler, so the two fields remain neutral
|
||||
// until their gameplay meanings are proved separately.
|
||||
type RaidDoBehaviorRequest struct {
|
||||
Value0 uint32
|
||||
Value1 uint16
|
||||
}
|
||||
|
||||
// RaidMessageRequest is the exact C2S 663 report. MessageBytes preserves the
|
||||
// current DSTR bytes without assuming an encoding at the protocol boundary.
|
||||
// The current client has no corresponding ACK handler.
|
||||
type RaidMessageRequest struct {
|
||||
Mode byte
|
||||
MessageBytes []byte
|
||||
Tail byte
|
||||
}
|
||||
|
||||
// BuildRaidMovieSkipBody builds S2C 0x0257. Its native body is empty.
|
||||
func BuildRaidMovieSkipBody() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// BuildRaidSettlementResultBody builds the exact ten-byte S2C 0x0259 body.
|
||||
func BuildRaidSettlementResultBody(result RaidSettlementResult) ([]byte, error) {
|
||||
if result.ResultType > maxRaidResultType {
|
||||
return nil, errRaidResultTypeInvalid
|
||||
}
|
||||
if result.PhaseIndex > maxRaidResultPhase {
|
||||
return nil, errRaidResultPhaseInvalid
|
||||
}
|
||||
|
||||
var writer packetWriter
|
||||
writer.writeByte(result.ResultType)
|
||||
writer.writeByte(result.PhaseIndex)
|
||||
writer.writeUint32(result.ClearSeconds)
|
||||
writer.writeUint16(result.DeathCount)
|
||||
writer.writeByte(result.Score)
|
||||
writer.writeByte(result.Flags)
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidSettlementRewardsBody builds S2C 0x0258 in the current client's
|
||||
// nested member/card/reward order.
|
||||
func BuildRaidSettlementRewardsBody(rewards RaidSettlementRewards) ([]byte, error) {
|
||||
if err := validateRaidByteCount(len(rewards.Members)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, member := range rewards.Members {
|
||||
if err := validateRaidByteCount(len(member.Rewards)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, reward := range member.Rewards {
|
||||
if reward.Kind == 0 {
|
||||
emptySentinel := reward.ItemIDOrValue == 0 && reward.CountOrAux == 0
|
||||
completeItem := reward.ItemIDOrValue != 0 && reward.CountOrAux != 0
|
||||
if !emptySentinel && !completeItem {
|
||||
return nil, errRaidNormalRewardItemInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var writer packetWriter
|
||||
writer.writeByte(rewards.ResultType)
|
||||
writer.writeByte(byte(len(rewards.Members)))
|
||||
for _, member := range rewards.Members {
|
||||
writer.writeUint16(member.MemberKey)
|
||||
writer.writeByte(member.CardIndex)
|
||||
writer.writeByte(byte(len(member.Rewards)))
|
||||
for _, reward := range member.Rewards {
|
||||
writer.writeByte(reward.Kind)
|
||||
writer.writeUint32(reward.ItemIDOrValue)
|
||||
writer.writeUint16(reward.CountOrAux)
|
||||
}
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
// BuildRaidResultStageRequestBody builds the exact C2S 659 body used in byte
|
||||
// fixtures and protocol clients.
|
||||
func BuildRaidResultStageRequestBody(request RaidResultStageRequest) ([]byte, error) {
|
||||
if request.Stage > maxRaidResultPhase {
|
||||
return nil, errRaidResultStageInvalid
|
||||
}
|
||||
return []byte{request.Stage}, nil
|
||||
}
|
||||
|
||||
// DecodeRaidResultStageRequest parses the exact C2S 659 body.
|
||||
func DecodeRaidResultStageRequest(body []byte) (RaidResultStageRequest, error) {
|
||||
if len(body) != 1 {
|
||||
return RaidResultStageRequest{}, errRaidResultStageBodyInvalid
|
||||
}
|
||||
request := RaidResultStageRequest{Stage: body[0]}
|
||||
if request.Stage > maxRaidResultPhase {
|
||||
return RaidResultStageRequest{}, errRaidResultStageInvalid
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// BuildRaidResultStageAckBody builds the empty ACK body consumed by the
|
||||
// current client's no-op C2S 659 ACK handler.
|
||||
func BuildRaidResultStageAckBody() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
// BuildRaidRewardCardRequestBody builds the exact C2S 660 body used in byte
|
||||
// fixtures and protocol clients.
|
||||
func BuildRaidRewardCardRequestBody(request RaidRewardCardRequest) ([]byte, error) {
|
||||
if request.Mode > maxRaidRewardCardMode {
|
||||
return nil, errRaidRewardCardModeInvalid
|
||||
}
|
||||
return []byte{request.Mode, request.CardIndex}, nil
|
||||
}
|
||||
|
||||
// DecodeRaidRewardCardRequest parses the exact C2S 660 body.
|
||||
func DecodeRaidRewardCardRequest(body []byte) (RaidRewardCardRequest, error) {
|
||||
if len(body) != 2 {
|
||||
return RaidRewardCardRequest{}, errRaidRewardCardBodyInvalid
|
||||
}
|
||||
request := RaidRewardCardRequest{Mode: body[0], CardIndex: body[1]}
|
||||
if request.Mode > maxRaidRewardCardMode {
|
||||
return RaidRewardCardRequest{}, errRaidRewardCardModeInvalid
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// BuildRaidRewardCardAckBody builds the current C2S 660 ACK. Success consumes
|
||||
// only one byte; failure consumes the reason byte as well.
|
||||
func BuildRaidRewardCardAckBody(success bool, reason byte) []byte {
|
||||
if success {
|
||||
return []byte{1}
|
||||
}
|
||||
return []byte{0, reason}
|
||||
}
|
||||
|
||||
// DecodeRaidDoBehaviorRequest parses the exact u32,u16 C2S 661 body.
|
||||
func DecodeRaidDoBehaviorRequest(body []byte) (RaidDoBehaviorRequest, error) {
|
||||
if len(body) != 6 {
|
||||
return RaidDoBehaviorRequest{}, errRaidDoBehaviorBodyInvalid
|
||||
}
|
||||
return RaidDoBehaviorRequest{
|
||||
Value0: binary.LittleEndian.Uint32(body[:4]),
|
||||
Value1: binary.LittleEndian.Uint16(body[4:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeRaidMessageRequest parses the exact u8,DSTR,u8 C2S 663 body.
|
||||
func DecodeRaidMessageRequest(body []byte) (RaidMessageRequest, error) {
|
||||
if len(body) < 6 {
|
||||
return RaidMessageRequest{}, errRaidMessageBodyInvalid
|
||||
}
|
||||
messageLength := uint64(binary.LittleEndian.Uint32(body[1:5]))
|
||||
messageEnd := uint64(5) + messageLength
|
||||
if messageEnd > uint64(len(body)) {
|
||||
return RaidMessageRequest{}, errRaidMessageDSTRTruncated
|
||||
}
|
||||
if messageEnd+1 != uint64(len(body)) {
|
||||
return RaidMessageRequest{}, errRaidMessageBodyInvalid
|
||||
}
|
||||
return RaidMessageRequest{
|
||||
Mode: body[0],
|
||||
MessageBytes: append([]byte(nil), body[5:int(messageEnd)]...),
|
||||
Tail: body[messageEnd],
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentRaidResultFlowMessageIDs(t *testing.T) {
|
||||
if RaidMovieMsgID != 0x0257 || RaidRewardListMsgID != 0x0258 || RaidResultMsgID != 0x0259 {
|
||||
t.Fatalf("S2C ids = %#04x/%#04x/%#04x", RaidMovieMsgID, RaidRewardListMsgID, RaidResultMsgID)
|
||||
}
|
||||
if RaidResultStageRequestMsgID != 0x0293 || RaidRewardCardRequestMsgID != 0x0294 ||
|
||||
RaidDoBehaviorRequestMsgID != 0x0295 || RaidMessageRequestMsgID != 0x0297 {
|
||||
t.Fatalf("C2S ids = %#04x/%#04x/%#04x/%#04x", RaidResultStageRequestMsgID, RaidRewardCardRequestMsgID, RaidDoBehaviorRequestMsgID, RaidMessageRequestMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidDoBehaviorRequestIsExactU32U16WithoutAck(t *testing.T) {
|
||||
request, err := DecodeRaidDoBehaviorRequest([]byte{0x44, 0x33, 0x22, 0x11, 0x66, 0x55})
|
||||
if err != nil {
|
||||
t.Fatalf("decode behavior: %v", err)
|
||||
}
|
||||
if request.Value0 != 0x11223344 || request.Value1 != 0x5566 {
|
||||
t.Fatalf("behavior = %+v", request)
|
||||
}
|
||||
for _, body := range [][]byte{nil, make([]byte, 5), make([]byte, 7)} {
|
||||
if _, err := DecodeRaidDoBehaviorRequest(body); err != errRaidDoBehaviorBodyInvalid {
|
||||
t.Fatalf("behavior body len=%d error=%v", len(body), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidMessageRequestIsExactU8DSTRU8WithoutAck(t *testing.T) {
|
||||
body := []byte{2, 3, 0, 0, 0, 'a', 'b', 'c', 7}
|
||||
request, err := DecodeRaidMessageRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("decode message: %v", err)
|
||||
}
|
||||
if request.Mode != 2 || request.Tail != 7 || !bytes.Equal(request.MessageBytes, []byte("abc")) {
|
||||
t.Fatalf("message = %+v", request)
|
||||
}
|
||||
request.MessageBytes[0] = 'z'
|
||||
if body[5] != 'a' {
|
||||
t.Fatalf("decoded message aliases caller body")
|
||||
}
|
||||
|
||||
for _, body := range [][]byte{nil, {0, 0, 0, 0, 0}, {0, 2, 0, 0, 0, 'a', 0}} {
|
||||
if _, err := DecodeRaidMessageRequest(body); err != errRaidMessageBodyInvalid && err != errRaidMessageDSTRTruncated {
|
||||
t.Fatalf("message body % X error=%v", body, err)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeRaidMessageRequest([]byte{0, 1, 0, 0, 0, 'a', 0, 0}); err != errRaidMessageBodyInvalid {
|
||||
t.Fatalf("message trailing byte error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidMovieSkipBodyIsExactlyEmpty(t *testing.T) {
|
||||
if got := BuildRaidMovieSkipBody(); got == nil || len(got) != 0 {
|
||||
t.Fatalf("movie-skip body = % X, want non-nil empty body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidSettlementResultBodyExactTenBytes(t *testing.T) {
|
||||
got, err := BuildRaidSettlementResultBody(RaidSettlementResult{
|
||||
ResultType: 2,
|
||||
PhaseIndex: 1,
|
||||
ClearSeconds: 0x11223344,
|
||||
DeathCount: 0x5566,
|
||||
Score: 0x77,
|
||||
Flags: 0x88,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidSettlementResultBody error = %v", err)
|
||||
}
|
||||
want := []byte{2, 1, 0x44, 0x33, 0x22, 0x11, 0x66, 0x55, 0x77, 0x88}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("result body = % X, want % X", got, want)
|
||||
}
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("result body len = %d, want 10", len(got))
|
||||
}
|
||||
|
||||
minimal, err := BuildRaidSettlementResultBody(RaidSettlementResult{ClearSeconds: 300})
|
||||
if err != nil {
|
||||
t.Fatalf("minimal result error = %v", err)
|
||||
}
|
||||
if want := []byte{0, 0, 0x2c, 0x01, 0, 0, 0, 0, 0, 0}; !bytes.Equal(minimal, want) {
|
||||
t.Fatalf("minimal result body = % X, want % X", minimal, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidSettlementResultBodyRejectsNativeRangeOverflow(t *testing.T) {
|
||||
if _, err := BuildRaidSettlementResultBody(RaidSettlementResult{ResultType: 4}); err != errRaidResultTypeInvalid {
|
||||
t.Fatalf("result type error = %v, want %v", err, errRaidResultTypeInvalid)
|
||||
}
|
||||
if _, err := BuildRaidSettlementResultBody(RaidSettlementResult{PhaseIndex: 2}); err != errRaidResultPhaseInvalid {
|
||||
t.Fatalf("phase error = %v, want %v", err, errRaidResultPhaseInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidSettlementRewardsBodyExactNestedBytes(t *testing.T) {
|
||||
got, err := BuildRaidSettlementRewardsBody(RaidSettlementRewards{
|
||||
ResultType: 1,
|
||||
Members: []RaidSettlementMemberRewards{
|
||||
{
|
||||
MemberKey: 0x1234,
|
||||
CardIndex: 5,
|
||||
Rewards: []RaidSettlementReward{
|
||||
{Kind: 0, ItemIDOrValue: 0x11223344, CountOrAux: 0x5566},
|
||||
{},
|
||||
},
|
||||
},
|
||||
{MemberKey: 0xabcd, CardIndex: 9},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidSettlementRewardsBody error = %v", err)
|
||||
}
|
||||
want := []byte{
|
||||
1, 2,
|
||||
0x34, 0x12, 5, 2,
|
||||
0, 0x44, 0x33, 0x22, 0x11, 0x66, 0x55,
|
||||
0, 0, 0, 0, 0, 0, 0,
|
||||
0xcd, 0xab, 9, 0,
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("reward body = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaidSettlementRewardsBodyValidatesCountsAndNormalItems(t *testing.T) {
|
||||
if _, err := BuildRaidSettlementRewardsBody(RaidSettlementRewards{
|
||||
Members: make([]RaidSettlementMemberRewards, maxRaidByteCount+1),
|
||||
}); err != errRaidByteCountOverflow {
|
||||
t.Fatalf("member overflow error = %v, want %v", err, errRaidByteCountOverflow)
|
||||
}
|
||||
if _, err := BuildRaidSettlementRewardsBody(RaidSettlementRewards{
|
||||
Members: []RaidSettlementMemberRewards{{
|
||||
Rewards: make([]RaidSettlementReward, maxRaidByteCount+1),
|
||||
}},
|
||||
}); err != errRaidByteCountOverflow {
|
||||
t.Fatalf("reward overflow error = %v, want %v", err, errRaidByteCountOverflow)
|
||||
}
|
||||
|
||||
invalidItems := []RaidSettlementReward{
|
||||
{Kind: 0, ItemIDOrValue: 1},
|
||||
{Kind: 0, CountOrAux: 1},
|
||||
}
|
||||
for _, reward := range invalidItems {
|
||||
if _, err := BuildRaidSettlementRewardsBody(RaidSettlementRewards{
|
||||
Members: []RaidSettlementMemberRewards{{Rewards: []RaidSettlementReward{reward}}},
|
||||
}); err != errRaidNormalRewardItemInvalid {
|
||||
t.Fatalf("partial normal item %+v error = %v, want %v", reward, err, errRaidNormalRewardItemInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := BuildRaidSettlementRewardsBody(RaidSettlementRewards{
|
||||
Members: []RaidSettlementMemberRewards{{Rewards: []RaidSettlementReward{{Kind: 1}}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("unproved non-zero reward kind should remain encodable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidResultStageRequestExactBodyAndDecode(t *testing.T) {
|
||||
for _, stage := range []byte{0, 1} {
|
||||
got, err := BuildRaidResultStageRequestBody(RaidResultStageRequest{Stage: stage})
|
||||
if err != nil {
|
||||
t.Fatalf("build stage %d error = %v", stage, err)
|
||||
}
|
||||
if want := []byte{stage}; !bytes.Equal(got, want) {
|
||||
t.Fatalf("stage %d body = % X, want % X", stage, got, want)
|
||||
}
|
||||
decoded, err := DecodeRaidResultStageRequest(got)
|
||||
if err != nil || decoded.Stage != stage {
|
||||
t.Fatalf("decode stage %d = %+v, %v", stage, decoded, err)
|
||||
}
|
||||
}
|
||||
if _, err := BuildRaidResultStageRequestBody(RaidResultStageRequest{Stage: 2}); err != errRaidResultStageInvalid {
|
||||
t.Fatalf("invalid build stage error = %v", err)
|
||||
}
|
||||
for _, body := range [][]byte{nil, {0, 1}} {
|
||||
if _, err := DecodeRaidResultStageRequest(body); err != errRaidResultStageBodyInvalid {
|
||||
t.Fatalf("stage body % X error = %v, want %v", body, err, errRaidResultStageBodyInvalid)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeRaidResultStageRequest([]byte{2}); err != errRaidResultStageInvalid {
|
||||
t.Fatalf("invalid decoded stage error = %v", err)
|
||||
}
|
||||
if got := BuildRaidResultStageAckBody(); got == nil || len(got) != 0 {
|
||||
t.Fatalf("stage ACK = % X, want non-nil empty body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaidRewardCardRequestExactBodyDecodeAndAck(t *testing.T) {
|
||||
for _, request := range []RaidRewardCardRequest{
|
||||
{Mode: 0, CardIndex: 0x11},
|
||||
{Mode: 1, CardIndex: 0x80},
|
||||
{Mode: 2, CardIndex: 0xff},
|
||||
} {
|
||||
got, err := BuildRaidRewardCardRequestBody(request)
|
||||
if err != nil {
|
||||
t.Fatalf("build %+v error = %v", request, err)
|
||||
}
|
||||
if want := []byte{request.Mode, request.CardIndex}; !bytes.Equal(got, want) {
|
||||
t.Fatalf("request %+v body = % X, want % X", request, got, want)
|
||||
}
|
||||
decoded, err := DecodeRaidRewardCardRequest(got)
|
||||
if err != nil || decoded != request {
|
||||
t.Fatalf("decode % X = %+v, %v; want %+v", got, decoded, err, request)
|
||||
}
|
||||
}
|
||||
if _, err := BuildRaidRewardCardRequestBody(RaidRewardCardRequest{Mode: 3}); err != errRaidRewardCardModeInvalid {
|
||||
t.Fatalf("invalid build mode error = %v", err)
|
||||
}
|
||||
for _, body := range [][]byte{nil, {0}, {0, 1, 2}} {
|
||||
if _, err := DecodeRaidRewardCardRequest(body); err != errRaidRewardCardBodyInvalid {
|
||||
t.Fatalf("card body % X error = %v, want %v", body, err, errRaidRewardCardBodyInvalid)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeRaidRewardCardRequest([]byte{3, 0}); err != errRaidRewardCardModeInvalid {
|
||||
t.Fatalf("invalid decoded mode error = %v", err)
|
||||
}
|
||||
if got, want := BuildRaidRewardCardAckBody(true, 0xff), []byte{1}; !bytes.Equal(got, want) {
|
||||
t.Fatalf("success ACK = % X, want % X", got, want)
|
||||
}
|
||||
if got, want := BuildRaidRewardCardAckBody(false, 0x7e), []byte{0, 0x7e}; !bytes.Equal(got, want) {
|
||||
t.Fatalf("failure ACK = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 攻坚队名的 dstr 载荷必须是客户端自己的窄字节编码(GB18030),而不是 UTF-16LE。
|
||||
//
|
||||
// 实证 —— 2026-09-27 实机抓包(D:\test\...\runtime\logs\packet_log.txt),
|
||||
// op591 mode=0 的对象基座:
|
||||
//
|
||||
// 01 00 17 00 | 00 00 00 00 | 01 00 17 00 | 06 00 00 00 | 3c 79 7d 76 31 00 | ...
|
||||
// raid_key mode=0 raid_key dstr len=6 "礼白1" 的 UTF-16LE
|
||||
//
|
||||
// 同一包里紧随其后的队长记录用的是 c0 f1 b0 d7 36 36 36 = GB18030 "礼白666"。
|
||||
// 两者用同一个读取器:sub_3457D20(dst, 0x100, GetString(0x29D)),它和线上其它
|
||||
// dstr 一样读「u32 长度 + 窄字节」,再逐字节宽化(0x3457D20 -> 0x34D9CA0)。
|
||||
//
|
||||
// 所以 UTF-16LE 载荷会被当成当前代码页逐字节渲染,症状由名字字节决定:
|
||||
//
|
||||
// "1111" -> 31 00 31 00 ... 读到第一个 00 就结束,只显示 "1"
|
||||
// "礼白1" -> 3c 79 7d 76 31 00 逐字节 = '<' 'y' '}' 'v' '1',即 "<y}v1"
|
||||
//
|
||||
// 86 参考端同族:RaidManager.Create 存请求自带的 TitleBytes,RaidPacketBuilder
|
||||
// .WriteRaidObject 用 WriteRawDstr 原样发出;其 RaidHandler 自测把抓包到的
|
||||
// CREATE_RAID 正文钉成窄字节 "123"(31 32 33),不是 31 00 32 00 33 00。
|
||||
func TestRaidObjectTitleCarriesClientEncodedBytesNotUTF16(t *testing.T) {
|
||||
// 礼白1 的 GB18030 字节:C0 F1(礼) B0 D7(白) 31(1) —— 5 字节,奇数长度。
|
||||
gb18030 := []byte{0xc0, 0xf1, 0xb0, 0xd7, 0x31}
|
||||
// 同一串的 UTF-16LE 拼写,正是实机抓到的那 6 个字节。
|
||||
utf16LE := []byte{0x3c, 0x79, 0x7d, 0x76, 0x31, 0x00}
|
||||
|
||||
object := RaidObjectMode0{
|
||||
RaidKey: 0x00170001,
|
||||
TitleBytes: gb18030,
|
||||
Leader: MemberRecord{CharKey: 1, RawNameBytes: []byte("leader")},
|
||||
Members: []MemberRecord{{CharKey: 1, RawNameBytes: []byte("leader")}},
|
||||
}
|
||||
|
||||
body, err := BuildRaidObjectMode0Body(object)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidObjectMode0Body error = %v", err)
|
||||
}
|
||||
// op591 = u32 raid_key + u32 mode + [对象基座],标题 dstr 因此在 12。
|
||||
if got := raidTitleDstrAt(t, body, 12); !bytes.Equal(got, gb18030) {
|
||||
t.Fatalf("op591 标题 dstr = % X,want % X(客户端 GB18030 字节)", got, gb18030)
|
||||
}
|
||||
if bytes.Contains(body, utf16LE) {
|
||||
t.Fatalf("op591 包里仍有 UTF-16LE 拼写 % X", utf16LE)
|
||||
}
|
||||
|
||||
// op753 的公共目录行 = 三个 u32 页头(12 字节)+ 同一个对象基座。
|
||||
public, err := BuildPublicRaidListBody(PublicRaidListPage{TotalCount: 1, Objects: []RaidObjectMode0{object}})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPublicRaidListBody error = %v", err)
|
||||
}
|
||||
if got := raidTitleDstrAt(t, public, 16); !bytes.Equal(got, gb18030) {
|
||||
t.Fatalf("op753 标题 dstr = % X,want % X(客户端 GB18030 字节)", got, gb18030)
|
||||
}
|
||||
|
||||
// 奇数长度的 GB18030 名必须能构造:旧校验要求 len%2==0,"礼白1" 会被整包拒掉。
|
||||
if len(gb18030)%2 == 0 {
|
||||
t.Fatal("这条用例本身失效了:GB18030 名字应当是奇数长度")
|
||||
}
|
||||
}
|
||||
|
||||
// 纯数字名是症状最容易复现的一档:UTF-16LE 的 31 00 会读到第一个 00 就截断,
|
||||
// 于是 "1111" 只显示 "1"。窄字节下发后 dstr 长度就是 4。
|
||||
func TestRaidObjectTitleDigitsOnlyStaysWholeName(t *testing.T) {
|
||||
object := RaidObjectMode0{
|
||||
RaidKey: 0x00170001,
|
||||
TitleBytes: []byte("1111"),
|
||||
Leader: MemberRecord{CharKey: 1, RawNameBytes: []byte("leader")},
|
||||
}
|
||||
body, err := BuildRaidObjectMode0Body(object)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidObjectMode0Body error = %v", err)
|
||||
}
|
||||
if got := len(raidTitleDstrAt(t, body, 12)); got != 4 {
|
||||
t.Fatalf("纯数字名 dstr 长 = %d,want 4(UTF-16LE 会是 8)", got)
|
||||
}
|
||||
if got := raidTitleDstrAt(t, body, 12); !bytes.Equal(got, []byte("1111")) {
|
||||
t.Fatalf("纯数字名 dstr = % X,want 31 31 31 31", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 空名发空 dstr(u32 长度 0),与 86 参考端一致:客户端会退回自己的兜底串
|
||||
// (sub_3457D20 的 GetString(0x29D)),而不是收到一个 NUL 名。
|
||||
func TestRaidObjectEmptyTitleSendsEmptyDstr(t *testing.T) {
|
||||
object := RaidObjectMode0{
|
||||
RaidKey: 0x00170001,
|
||||
Leader: MemberRecord{CharKey: 1, RawNameBytes: []byte("leader")},
|
||||
}
|
||||
body, err := BuildRaidObjectMode0Body(object)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRaidObjectMode0Body error = %v", err)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(body[12:16]); got != 0 {
|
||||
t.Fatalf("空名 dstr 声明长度 = %d,want 0", got)
|
||||
}
|
||||
if got := raidTitleDstrAt(t, body, 12); len(got) != 0 {
|
||||
t.Fatalf("空名 dstr 载荷 = % X,want 空", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端读取器的容量是 0x100:声明长度达到它就整格丢弃并退回兜底串,所以在
|
||||
// 构造层直接拒绝,而不是截断成一个看起来正常、实际被丢弃的名字。
|
||||
func TestRaidObjectTitleAtReaderCapacityIsRejected(t *testing.T) {
|
||||
object := RaidObjectMode0{
|
||||
RaidKey: 0x00170001,
|
||||
TitleBytes: bytes.Repeat([]byte{'x'}, raidTitleMaxBytes),
|
||||
Leader: MemberRecord{CharKey: 1, RawNameBytes: []byte("leader")},
|
||||
}
|
||||
if _, err := BuildRaidObjectMode0Body(object); err == nil {
|
||||
t.Fatalf("长度 %d 的标题被接受了;客户端读到 0x100 会整格丢弃", raidTitleMaxBytes)
|
||||
}
|
||||
|
||||
object.TitleBytes = bytes.Repeat([]byte{'x'}, raidTitleMaxBytes-1)
|
||||
if _, err := BuildRaidObjectMode0Body(object); err != nil {
|
||||
t.Fatalf("长度 %d 的标题被拒了:%v", raidTitleMaxBytes-1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// raidTitleDstrAt 读 offset 处的「u32 长度 + 载荷」,并顺带把带内长度与实际
|
||||
// 可读字节对齐,避免用例在包被截断时只报一个越界 panic。
|
||||
func raidTitleDstrAt(t *testing.T, body []byte, offset int) []byte {
|
||||
t.Helper()
|
||||
if len(body) < offset+4 {
|
||||
t.Fatalf("包体长 %d,读不下 offset %d 的 dstr 长度", len(body), offset)
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
|
||||
if length < 0 || offset+4+length > len(body) {
|
||||
t.Fatalf("dstr 声明长度 %d 越过包尾(包体长 %d,offset %d)", length, len(body), offset)
|
||||
}
|
||||
return body[offset+4 : offset+4+length]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 本文件提供攻坚队 S2C body 构造需要的最小二进制写入器。
|
||||
package raid
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
type packetWriter struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeByte(value byte) {
|
||||
w.buf = append(w.buf, value)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeUint16(value uint16) {
|
||||
var data [2]byte
|
||||
binary.LittleEndian.PutUint16(data[:], value)
|
||||
w.buf = append(w.buf, data[:]...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeUint32(value uint32) {
|
||||
var data [4]byte
|
||||
binary.LittleEndian.PutUint32(data[:], value)
|
||||
w.buf = append(w.buf, data[:]...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeRawDstr(data []byte) {
|
||||
var length [4]byte
|
||||
binary.LittleEndian.PutUint32(length[:], uint32(len(data)))
|
||||
w.buf = append(w.buf, length[:]...)
|
||||
w.buf = append(w.buf, data...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeRaw(data []byte) {
|
||||
w.buf = append(w.buf, data...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) bytes() []byte {
|
||||
return append([]byte(nil), w.buf...)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package raid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
// 临时探针:把 PVF 里与 BOSS 机制有关的规则原样打出来,看 **boss pattern init**
|
||||
// 到底挂在什么条件下、以及是否存在"无条件规则"(空条件会在每一个事件上重放)。
|
||||
func TestZZProbeFiendWarBossRules(t *testing.T) {
|
||||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||||
if path == "" {
|
||||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||||
}
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadFiendWarConfig(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c == nil || c.Battle == nil {
|
||||
t.Fatal("no battle config")
|
||||
}
|
||||
show := func(i FiendWarInstruction) string {
|
||||
args := make([]string, 0, len(i.Args))
|
||||
for _, a := range i.Args {
|
||||
args = append(args, fmt.Sprintf("%d/%q", a.Int, a.Value))
|
||||
}
|
||||
return fmt.Sprintf("op=%s sym=%d args=%v", i.Op, i.Symbol, args)
|
||||
}
|
||||
for index, rule := range c.Battle.Rules {
|
||||
interest := false
|
||||
for _, a := range rule.Actions {
|
||||
if a.Op == "boss pattern init" || a.Op == "clear phase" || a.Op == "fail fiendwar" {
|
||||
interest = true
|
||||
}
|
||||
}
|
||||
for _, cond := range rule.Conditions {
|
||||
if cond.Op == "on enter map" {
|
||||
interest = true
|
||||
}
|
||||
}
|
||||
if len(rule.Conditions) == 0 {
|
||||
interest = true
|
||||
t.Logf("rule %d: **** UNCONDITIONAL (conditions empty) ****", index)
|
||||
}
|
||||
if !interest {
|
||||
continue
|
||||
}
|
||||
t.Logf("rule %d: %d conditions", index, len(rule.Conditions))
|
||||
for _, cond := range rule.Conditions {
|
||||
t.Logf(" IF %s", show(cond))
|
||||
}
|
||||
for _, a := range rule.Actions {
|
||||
t.Logf(" THEN %s", show(a))
|
||||
}
|
||||
}
|
||||
t.Logf("total rules=%d", len(c.Battle.Rules))
|
||||
// BOSS 区域的时限:Enter 会把 area.Deadline = now + LimitSeconds,超时 Tick 会
|
||||
// **Abandon**(而 Abandon 对 boss 区域会 BossPatternDone 清零、pattern 归零)。
|
||||
for id, area := range c.Battle.Areas {
|
||||
if area.Kind == "top boss" || area.Kind == "bot boss" {
|
||||
t.Logf("area %d: kind=%q dungeon=%d limit_seconds=%d member_max=%d",
|
||||
id, area.Kind, area.Dungeon, area.LimitSeconds, area.MemberMax)
|
||||
}
|
||||
}
|
||||
for name, id := range c.Battle.Symbols {
|
||||
if name == "top boss state" || name == "bot boss state" || name == "boss pattern state" ||
|
||||
name == "top boss hp" || name == "bot boss hp" {
|
||||
t.Logf("symbol %q = %d", name, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user