DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
}
+787
View File
@@ -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
}
+5
View File
@@ -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)
}
+241
View File
@@ -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()
}
+297
View File
@@ -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)
}
}
}