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

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1045 lines
39 KiB
Go

package raid
import (
"errors"
"fmt"
"math"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
// AntonPVFPath is the only archive member read by this catalog.
AntonPVFPath = "etc/raid/anton.etc"
antonOrdinaryPhaseCount = 2
antonMovieEndValueCount = 4
antonSingleRaidPhaseName = "single raid phase"
)
var (
ErrAntonPVFSourceRequired = errors.New("anton raid PVF source is required")
ErrAntonPVFDocumentInvalid = errors.New("anton raid PVF document is invalid")
ErrAntonPVFSectionMissing = errors.New("anton raid PVF section is missing")
ErrAntonPVFSectionShape = errors.New("anton raid PVF section shape is invalid")
ErrAntonPVFValueInvalid = errors.New("anton raid PVF value is invalid")
ErrAntonPVFDuplicateDungeon = errors.New("anton raid PVF dungeon is duplicated")
)
// AntonDungeonState is one native [INIT STATE] value.
type AntonDungeonState string
const (
AntonDungeonOpen AntonDungeonState = "open"
AntonDungeonHide AntonDungeonState = "hide"
AntonDungeonClosed AntonDungeonState = "close"
// AntonDungeonCleared is the runtime-only "this gate has been finished"
// state. etc/raid/anton.etc never spells it -- the parser below still accepts
// only the three PVF words -- but the client draws it differently from a
// merely hidden gate, and the 86JP reference server sets it (its value 3) the
// moment a gate's clear count reaches its requirement
// (RaidHandler.Progression.cs:240 for the legs, ClearBlackFogSourceAsync for
// the regenerated fog).
AntonDungeonCleared AntonDungeonState = "cleared"
)
// AntonEntryCost is the item/count pair inside the global [ENTRY COST] block.
type AntonEntryCost struct {
ItemID uint32
Count uint32
}
// AntonStateReward preserves one [STATE REWARD] row in source order. ItemID is
// the reward-definition item, not an instruction to grant that item directly.
type AntonStateReward struct {
Kind string
Rank int
Weight int
DefinitionItemID uint32
Flag byte
}
// AntonRankCondition is one [DEAD COUNT] row inside a phase's
// [CONDITION FOR RANK]: the inclusive team death-count range that earns Rank.
// The rows are kept in source order and raw. The current catalogs leave a
// one-death gap between adjacent rows (0-1, 2-5, 6-9, 10-14), so collapsing
// them into thresholds would invent a rule for the gap.
type AntonRankCondition struct {
MinimumDeaths int
MaximumDeaths int
Rank int
}
// AntonDungeonInfo preserves one ordinary-phase [DUNGEON INFO] block.
type AntonDungeonInfo struct {
Index uint32
InitState AntonDungeonState
HP int
PartyMax int
// LockoutType is the native [LOCKOUT TYPE] pair. Only the Luke catalog
// carries the section (its phase-two gates use 4 on the light route, 5 on
// the dark one and 0 on the two shared rooms); etc/raid/anton.etc omits it
// and leaves the pair at zero. What the value gates is not proven by the
// PVF alone, so both numbers stay raw.
LockoutType [2]int
// DestroyObjects lists the object-group markers declared under this gate's
// [DESTROY OBJECT INFO], in source order and with the native section
// spelling. The section has no closing tag, so its entries run to the end
// of the [DUNGEON INFO] block. Only the Luke phase-one sanctums declare it
// ("birth sanctum object" and its two siblings).
DestroyObjects []string
}
// AntonPhase preserves one phase block and its ordered rewards and dungeons.
// The single-mode phase uses the same shape even though its PVF block omits
// the ordinary minimum-clear-count section.
type AntonPhase struct {
Name string
MinimalDungeonClearCount int
Rewards []AntonStateReward
Dungeons []AntonDungeonInfo
// RankConditions is the phase's [CONDITION FOR RANK] table. Every phase of
// every current raid catalog declares one, including the single-mode phase.
RankConditions []AntonRankCondition
// CoinLimit is the optional [PHASE COIN LIMIT] revive-coin cap. Zero means
// the phase declares no cap, which is every Anton phase and Luke's first.
CoinLimit int
}
// AntonRaidModeKind identifies a mode advertised by the native Anton PVF.
type AntonRaidModeKind string
const (
AntonRaidModeTeam AntonRaidModeKind = "team"
AntonRaidModeSingle AntonRaidModeKind = "single"
)
// AntonRaidModeChoice is the small, read-only projection needed by a mode
// selection UI. The complete phase data remains available on AntonConfig.
type AntonRaidModeChoice struct {
Kind AntonRaidModeKind
PhaseNames []string
StartMinimumMembers int
TimeLimitSeconds []int
}
// AntonSingleModeConfig is the complete phase data declared by the native
// [SINGLE RAID PHASE]. Trigger expressions which are client/server script
// details remain opaque; the dungeon graph is advanced by the runtime owner.
type AntonSingleModeConfig struct {
Name string
TimeLimitSeconds int
Phase AntonPhase
}
// AntonConfig is the strictly validated, read-only subset of the current
// etc/raid/anton.etc needed by both ordinary and single-mode raid runtimes.
type AntonConfig struct {
PhaseMax int
MemberMax int
StartMinimumMembers int
SingleStartMinimumMembers int
StartDelaySeconds int
// DisconnMemberWaitTime is the global [DISCONN MEMBER WAIT TIME]: how long a
// dropped member's attack-party seat is held before the member is removed.
// Both shipped raids declare 300 (etc/raid/anton.etc and
// etc/raid/luke/luke.etc). Zero means the document does not declare it, and
// the runtime then keeps its own 300-second default.
DisconnMemberWaitTime int
PhaseTimeoutSeconds []int
PhaseBreakSeconds int
MovieEndSeconds []int
EntryCost AntonEntryCost
// WeeklyEnterCount preserves the two global [WEEKLY ENTER COUNT] values in
// source order (Luke 20 2, Anton 30 3). The client draws a weekly counter of
// its own (dstr 75388 "周卢克 %d/%d"), but which of the two values is the
// roster limit and which the per-character share is not proven here, so both
// stay raw and no admission policy is derived from them yet.
WeeklyEnterCount [2]int
Phases []AntonPhase
SingleMode *AntonSingleModeConfig
}
// ModeChoices returns the native team/single choices in PVF order. It returns
// fresh slices so callers can use the projection for UI rendering without
// mutating the cached configuration. No lifecycle state is created or changed.
func (config *AntonConfig) ModeChoices() []AntonRaidModeChoice {
if config == nil {
return nil
}
choices := make([]AntonRaidModeChoice, 0, 2)
if len(config.Phases) > 0 {
choices = append(choices, AntonRaidModeChoice{
Kind: AntonRaidModeTeam,
PhaseNames: antonPhaseNames(config.Phases),
StartMinimumMembers: config.StartMinimumMembers,
TimeLimitSeconds: append([]int(nil), config.PhaseTimeoutSeconds...),
})
}
if config.SingleMode != nil {
choices = append(choices, AntonRaidModeChoice{
Kind: AntonRaidModeSingle,
PhaseNames: []string{config.SingleMode.Name},
StartMinimumMembers: config.SingleStartMinimumMembers,
TimeLimitSeconds: []int{config.SingleMode.TimeLimitSeconds},
})
}
return choices
}
func antonPhaseNames(phases []AntonPhase) []string {
names := make([]string, 0, len(phases))
for _, phase := range phases {
names = append(names, phase.Name)
}
return names
}
// LoadAntonConfig reads exactly etc/raid/anton.etc and parses no other archive
// member. This makes the source boundary usable by both the runtime archive and
// small in-memory unit-test sources.
func LoadAntonConfig(source dnfpvf.Source) (*AntonConfig, error) {
if source == nil {
return nil, ErrAntonPVFSourceRequired
}
text, err := source.ReadText(AntonPVFPath)
if err != nil {
return nil, fmt.Errorf("read anton raid PVF %q: %w", AntonPVFPath, err)
}
document, err := dnfpvf.Parse(AntonPVFPath, text)
if err != nil {
return nil, fmt.Errorf("parse anton raid PVF %q: %w", AntonPVFPath, err)
}
return ParseAntonConfig(document)
}
// ParseAntonConfig converts one already-tokenized target document. It rejects
// a document for any other path so callers cannot accidentally feed a nearby
// raid file into the Anton catalog.
func ParseAntonConfig(document *dnfpvf.Document) (*AntonConfig, error) {
if document == nil || antonPathKey(document.Path) != antonPathKey(AntonPVFPath) {
path := ""
if document != nil {
path = document.Path
}
return nil, fmt.Errorf("%w: path=%q want=%q", ErrAntonPVFDocumentInvalid, path, AntonPVFPath)
}
phaseBlocks, err := antonFindBlocks(document, "phase", 0, len(document.Sections))
if err != nil {
return nil, err
}
if len(phaseBlocks) == 0 {
return nil, antonMissing("phase", "ordinary phases")
}
globalEnd := phaseBlocks[0].open
singleBlocks, err := antonFindBlocks(document, antonSingleRaidPhaseName, 0, len(document.Sections))
if err != nil {
return nil, err
}
if len(singleBlocks) > 1 {
return nil, antonShape(antonSingleRaidPhaseName, fmt.Sprintf("blocks=%d want<=1", len(singleBlocks)))
}
config := &AntonConfig{}
if config.PhaseMax, err = antonReadPositiveIntSection(document, "phase max", 0, globalEnd); err != nil {
return nil, err
}
if config.PhaseMax != antonOrdinaryPhaseCount {
return nil, antonInvalid("phase max", config.PhaseMax, fmt.Sprintf("want=%d", antonOrdinaryPhaseCount))
}
if len(phaseBlocks) != config.PhaseMax {
return nil, fmt.Errorf("%w: path=%s field=phase blocks got=%d want=%d", ErrAntonPVFSectionShape, AntonPVFPath, len(phaseBlocks), config.PhaseMax)
}
if config.MemberMax, err = antonReadPositiveIntSection(document, "raid member max", 0, globalEnd); err != nil {
return nil, err
}
if config.MemberMax > MaxAttackPartyMembers {
return nil, antonInvalid("raid member max", config.MemberMax, fmt.Sprintf("want<=%d", MaxAttackPartyMembers))
}
if config.StartMinimumMembers, err = antonReadPositiveIntSection(document, "raid start minimum member", 0, globalEnd); err != nil {
return nil, err
}
// MINIUM is the spelling in the current native PVF and is deliberately
// matched exactly instead of accepting an invented alias. Team-only PVFs
// may omit both this field and [SINGLE RAID PHASE]. Once a single block is
// declared, however, its minimum-member field is required.
singleMinimumIndex, hasSingleMinimum, findErr := antonOptionalUniqueSection(document, "single raid start minium member", 0, globalEnd)
if findErr != nil {
return nil, findErr
}
if hasSingleMinimum {
if config.SingleStartMinimumMembers, err = antonPositiveIntAt(document, singleMinimumIndex, "single raid start minium member"); err != nil {
return nil, err
}
} else if len(singleBlocks) == 1 {
return nil, antonMissing("single raid start minium member", "single raid phase")
}
if config.StartMinimumMembers > config.MemberMax {
return nil, antonInvalid("raid start minimum member", config.StartMinimumMembers, "want<=raid member max")
}
if config.SingleStartMinimumMembers > config.MemberMax {
return nil, antonInvalid("single raid start minium member", config.SingleStartMinimumMembers, "want<=raid member max")
}
if config.StartDelaySeconds, err = antonReadPositiveIntSection(document, "start delay time", 0, globalEnd); err != nil {
return nil, err
}
// [DISCONN MEMBER WAIT TIME] is read optionally: both shipped raids declare
// 300, but a document that omits the section must still parse, and the
// runtime keeps its own default for the zero.
disconnWaitIndex, hasDisconnWait, err := antonOptionalUniqueSection(document, "disconn member wait time", 0, globalEnd)
if err != nil {
return nil, err
}
if hasDisconnWait {
if config.DisconnMemberWaitTime, err = antonPositiveIntAt(document, disconnWaitIndex, "disconn member wait time"); err != nil {
return nil, err
}
}
if config.PhaseTimeoutSeconds, err = antonReadPositiveIntListSection(document, "phase time over", 0, globalEnd, config.PhaseMax); err != nil {
return nil, err
}
if config.PhaseBreakSeconds, err = antonReadPositiveIntSection(document, "phase break time", 0, globalEnd); err != nil {
return nil, err
}
if config.MovieEndSeconds, err = antonReadPositiveIntListSection(document, "movie end time", 0, globalEnd, antonMovieEndValueCount); err != nil {
return nil, err
}
if config.EntryCost, err = antonParseEntryCost(document, 0, globalEnd); err != nil {
return nil, err
}
if config.WeeklyEnterCount, err = antonParseWeeklyEnterCount(document, 0, globalEnd); err != nil {
return nil, err
}
seenDungeons := make(map[uint32]int)
config.Phases = make([]AntonPhase, 0, len(phaseBlocks))
for index, block := range phaseBlocks {
phase, parseErr := antonParsePhase(document, block, index, config.MemberMax, seenDungeons)
if parseErr != nil {
return nil, parseErr
}
config.Phases = append(config.Phases, phase)
}
// The native single-mode declaration follows the ordinary phases. Its
// trigger expressions remain opaque, while the phase name, rewards and
// dungeon definitions are consumed by the single-mode runtime owner.
if len(singleBlocks) == 1 {
singleBlock := singleBlocks[0]
timeLimitSeconds, readErr := antonReadPositiveIntSection(document, "single raid time over", 0, singleBlock.open)
if readErr != nil {
return nil, readErr
}
phaseName, parseErr := antonParseSingleModeName(document, singleBlock)
if parseErr != nil {
return nil, parseErr
}
phase, phaseErr := antonParseSinglePhase(document, singleBlock, config.MemberMax, seenDungeons)
if phaseErr != nil {
return nil, phaseErr
}
config.SingleMode = &AntonSingleModeConfig{Name: phaseName, TimeLimitSeconds: timeLimitSeconds, Phase: phase}
}
return config, nil
}
type antonSectionBlock struct {
open int
close int
}
func antonParseEntryCost(document *dnfpvf.Document, start, end int) (AntonEntryCost, error) {
blocks, err := antonFindBlocks(document, "entry cost", start, end)
if err != nil {
return AntonEntryCost{}, err
}
if len(blocks) == 0 {
return AntonEntryCost{}, antonMissing("entry cost", "global")
}
if len(blocks) != 1 {
return AntonEntryCost{}, antonShape("entry cost", fmt.Sprintf("blocks=%d want=1", len(blocks)))
}
block := blocks[0]
if block.close != block.open+1 {
return AntonEntryCost{}, antonShape("entry cost", "unexpected nested sections")
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return AntonEntryCost{}, err
}
tokens, err := antonSectionTokens(document, block.open)
if err != nil {
return AntonEntryCost{}, err
}
if len(tokens) != 2 {
return AntonEntryCost{}, antonShape("entry cost", fmt.Sprintf("values=%d want=2", len(tokens)))
}
itemID, err := antonPositiveUint32Token(tokens[0], "entry cost item id")
if err != nil {
return AntonEntryCost{}, err
}
count, err := antonPositiveUint32Token(tokens[1], "entry cost count")
if err != nil {
return AntonEntryCost{}, err
}
return AntonEntryCost{ItemID: itemID, Count: count}, nil
}
// antonParseWeeklyEnterCount reads the global [WEEKLY ENTER COUNT] pair.
// etc/raid/fiendwar/fiendwar.etc declares the same section twice at global
// scope; that catalog is not parsed by this grammar, and a duplicate stays a
// shape error here rather than a silently preferred first row.
func antonParseWeeklyEnterCount(document *dnfpvf.Document, start, end int) ([2]int, error) {
var values [2]int
sectionIndex, err := antonUniqueSection(document, "weekly enter count", start, end)
if err != nil {
return values, err
}
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return values, err
}
if len(tokens) != len(values) {
return values, antonShape("weekly enter count", fmt.Sprintf("values=%d want=%d", len(tokens), len(values)))
}
for index, token := range tokens {
value, valueErr := antonPositiveIntToken(token, "weekly enter count")
if valueErr != nil {
return [2]int{}, valueErr
}
values[index] = value
}
return values, nil
}
// antonParseRankConditions reads one phase's [CONDITION FOR RANK] block into
// its ordered [DEAD COUNT] rows. The block admits no other section.
func antonParseRankConditions(document *dnfpvf.Document, start, end int, context string) ([]AntonRankCondition, error) {
blocks, err := antonFindBlocks(document, "condition for rank", start, end)
if err != nil {
return nil, err
}
if len(blocks) == 0 {
return nil, antonMissing("condition for rank", context)
}
if len(blocks) != 1 {
return nil, antonShape("condition for rank", fmt.Sprintf("%s blocks=%d want=1", context, len(blocks)))
}
block := blocks[0]
if err := antonRequireEmptySection(document, block.open); err != nil {
return nil, err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return nil, err
}
rows := make([]AntonRankCondition, 0, block.close-block.open-1)
for index := block.open + 1; index < block.close; index++ {
if antonSectionName(document.Sections[index].Name) != "dead count" {
return nil, antonShape("condition for rank",
fmt.Sprintf("%s unexpected_section=%q", context, document.Sections[index].Name))
}
tokens, tokenErr := antonSectionTokens(document, index)
if tokenErr != nil {
return nil, tokenErr
}
if len(tokens) != 3 {
return nil, antonShape("dead count", fmt.Sprintf("%s values=%d want=3", context, len(tokens)))
}
var values [3]int
for position, token := range tokens {
value, valueErr := antonIntToken(token, "dead count")
if valueErr != nil {
return nil, valueErr
}
if value < 0 {
return nil, antonInvalid("dead count", value, fmt.Sprintf("%s want_nonnegative", context))
}
values[position] = value
}
if values[1] < values[0] {
return nil, antonInvalid("dead count", values[1], fmt.Sprintf("%s want>=minimum(%d)", context, values[0]))
}
rows = append(rows, AntonRankCondition{MinimumDeaths: values[0], MaximumDeaths: values[1], Rank: values[2]})
}
if len(rows) == 0 {
return nil, antonMissing("dead count", context)
}
return rows, nil
}
func antonParsePhase(document *dnfpvf.Document, block antonSectionBlock, phaseIndex, memberMax int, seenDungeons map[uint32]int) (AntonPhase, error) {
// The native Anton PVF uses PARTY MAX=0 for maps whose party size is
// governed by the attack-party rules rather than a dungeon-local cap.
// Preserve that value for ordinary and single maps alike.
return antonParsePhaseWithPartyMax(document, block, phaseIndex, memberMax, true, seenDungeons)
}
func antonParseSinglePhase(document *dnfpvf.Document, block antonSectionBlock, memberMax int, seenDungeons map[uint32]int) (AntonPhase, error) {
if err := antonRequireEmptySection(document, block.open); err != nil {
return AntonPhase{}, err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return AntonPhase{}, err
}
start, end := block.open+1, block.close
nameIndex, err := antonUniqueSection(document, "phase name", start, end)
if err != nil {
return AntonPhase{}, err
}
nameTokens, err := antonSectionTokens(document, nameIndex)
if err != nil {
return AntonPhase{}, err
}
if len(nameTokens) != 1 || nameTokens[0].Kind != dnfpvf.TokenString || strings.TrimSpace(nameTokens[0].Value) == "" {
return AntonPhase{}, antonShape("phase name", "single phase want=one nonempty string")
}
rewardBlocks, err := antonFindBlocks(document, "clear reward item", start, end)
if err != nil {
return AntonPhase{}, err
}
if len(rewardBlocks) != 1 {
return AntonPhase{}, antonShape("clear reward item", fmt.Sprintf("single phase blocks=%d want=1", len(rewardBlocks)))
}
rewards, err := antonParseRewards(document, rewardBlocks[0], 2)
if err != nil {
return AntonPhase{}, err
}
rankConditions, err := antonParseRankConditions(document, start, end, "single phase")
if err != nil {
return AntonPhase{}, err
}
dungeonBlocks, err := antonFindBlocks(document, "dungeon info", start, end)
if err != nil {
return AntonPhase{}, err
}
if len(dungeonBlocks) == 0 {
return AntonPhase{}, antonMissing("dungeon info", "single phase")
}
phase := AntonPhase{
Name: nameTokens[0].Value,
MinimalDungeonClearCount: 1,
Rewards: rewards,
Dungeons: make([]AntonDungeonInfo, 0, len(dungeonBlocks)),
RankConditions: rankConditions,
}
for _, dungeonBlock := range dungeonBlocks {
dungeon, parseErr := antonParseDungeonWithPartyMax(document, dungeonBlock, 2, memberMax, true)
if parseErr != nil {
return AntonPhase{}, parseErr
}
if firstPhase, exists := seenDungeons[dungeon.Index]; exists {
return AntonPhase{}, fmt.Errorf("%w: path=%s dungeon=%d first_phase=%d duplicate_phase=single", ErrAntonPVFDuplicateDungeon, AntonPVFPath, dungeon.Index, firstPhase+1)
}
seenDungeons[dungeon.Index] = 2
phase.Dungeons = append(phase.Dungeons, dungeon)
}
return phase, nil
}
func antonParsePhaseWithPartyMax(document *dnfpvf.Document, block antonSectionBlock, phaseIndex, memberMax int, allowZeroPartyMax bool, seenDungeons map[uint32]int) (AntonPhase, error) {
if err := antonRequireEmptySection(document, block.open); err != nil {
return AntonPhase{}, err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return AntonPhase{}, err
}
start, end := block.open+1, block.close
nameIndex, err := antonUniqueSection(document, "phase name", start, end)
if err != nil {
return AntonPhase{}, err
}
nameTokens, err := antonSectionTokens(document, nameIndex)
if err != nil {
return AntonPhase{}, err
}
if len(nameTokens) != 1 || nameTokens[0].Kind != dnfpvf.TokenString || strings.TrimSpace(nameTokens[0].Value) == "" {
return AntonPhase{}, antonShape("phase name", fmt.Sprintf("phase=%d want=one nonempty string", phaseIndex+1))
}
phase := AntonPhase{Name: nameTokens[0].Value}
rewardBlocks, err := antonFindBlocks(document, "clear reward item", start, end)
if err != nil {
return AntonPhase{}, err
}
if len(rewardBlocks) == 0 {
return AntonPhase{}, antonMissing("clear reward item", fmt.Sprintf("phase=%d", phaseIndex+1))
}
if len(rewardBlocks) != 1 {
return AntonPhase{}, antonShape("clear reward item", fmt.Sprintf("phase=%d blocks=%d want=1", phaseIndex+1, len(rewardBlocks)))
}
if nameIndex >= rewardBlocks[0].open {
return AntonPhase{}, antonShape("phase", fmt.Sprintf("phase=%d phase name must precede rewards", phaseIndex+1))
}
phase.Rewards, err = antonParseRewards(document, rewardBlocks[0], phaseIndex)
if err != nil {
return AntonPhase{}, err
}
phase.RankConditions, err = antonParseRankConditions(document, start, end, fmt.Sprintf("phase=%d", phaseIndex+1))
if err != nil {
return AntonPhase{}, err
}
// [PHASE COIN LIMIT] is declared only by Luke's second phase; every Anton
// phase omits it and keeps the zero.
coinIndex, hasCoinLimit, err := antonOptionalUniqueSection(document, "phase coin limit", start, end)
if err != nil {
return AntonPhase{}, err
}
if hasCoinLimit {
if phase.CoinLimit, err = antonPositiveIntAt(document, coinIndex, "phase coin limit"); err != nil {
return AntonPhase{}, err
}
}
minimalIndex, err := antonUniqueSection(document, "minimal dungeon clear count", start, end)
if err != nil {
return AntonPhase{}, err
}
if minimalIndex <= rewardBlocks[0].close {
return AntonPhase{}, antonShape("phase", fmt.Sprintf("phase=%d minimum clear count must follow rewards", phaseIndex+1))
}
phase.MinimalDungeonClearCount, err = antonPositiveIntAt(document, minimalIndex, "minimal dungeon clear count")
if err != nil {
return AntonPhase{}, err
}
dungeonBlocks, err := antonFindBlocks(document, "dungeon info", start, end)
if err != nil {
return AntonPhase{}, err
}
if len(dungeonBlocks) == 0 {
return AntonPhase{}, antonMissing("dungeon info", fmt.Sprintf("phase=%d", phaseIndex+1))
}
if dungeonBlocks[0].open <= minimalIndex {
return AntonPhase{}, antonShape("phase", fmt.Sprintf("phase=%d dungeons must follow minimum clear count", phaseIndex+1))
}
phase.Dungeons = make([]AntonDungeonInfo, 0, len(dungeonBlocks))
for _, dungeonBlock := range dungeonBlocks {
dungeon, parseErr := antonParseDungeonWithPartyMax(document, dungeonBlock, phaseIndex, memberMax, allowZeroPartyMax)
if parseErr != nil {
return AntonPhase{}, parseErr
}
if firstPhase, exists := seenDungeons[dungeon.Index]; exists {
return AntonPhase{}, fmt.Errorf("%w: path=%s dungeon=%d first_phase=%d duplicate_phase=%d", ErrAntonPVFDuplicateDungeon, AntonPVFPath, dungeon.Index, firstPhase+1, phaseIndex+1)
}
seenDungeons[dungeon.Index] = phaseIndex
phase.Dungeons = append(phase.Dungeons, dungeon)
}
if phase.MinimalDungeonClearCount > len(phase.Dungeons) {
return AntonPhase{}, antonInvalid("minimal dungeon clear count", phase.MinimalDungeonClearCount, fmt.Sprintf("phase=%d want<=dungeons(%d)", phaseIndex+1, len(phase.Dungeons)))
}
return phase, nil
}
func antonParseSingleModeName(document *dnfpvf.Document, block antonSectionBlock) (string, error) {
if err := antonRequireEmptySection(document, block.open); err != nil {
return "", err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return "", err
}
start, end := block.open+1, block.close
nameIndex, err := antonUniqueSection(document, "phase name", start, end)
if err != nil {
return "", err
}
nameTokens, err := antonSectionTokens(document, nameIndex)
if err != nil {
return "", err
}
if len(nameTokens) != 1 || nameTokens[0].Kind != dnfpvf.TokenString || strings.TrimSpace(nameTokens[0].Value) == "" {
return "", antonShape("phase name", "single phase want=one nonempty string")
}
return nameTokens[0].Value, nil
}
func antonParseRewards(document *dnfpvf.Document, block antonSectionBlock, phaseIndex int) ([]AntonStateReward, error) {
if err := antonRequireEmptySection(document, block.open); err != nil {
return nil, err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return nil, err
}
rewards := make([]AntonStateReward, 0, block.close-block.open-1)
for sectionIndex := block.open + 1; sectionIndex < block.close; sectionIndex++ {
if antonSectionName(document.Sections[sectionIndex].Name) != "state reward" {
return nil, antonShape("clear reward item", fmt.Sprintf("phase=%d unexpected_section=%q", phaseIndex+1, document.Sections[sectionIndex].Name))
}
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return nil, err
}
if len(tokens) != 5 || tokens[0].Kind != dnfpvf.TokenString || strings.TrimSpace(tokens[0].Value) == "" {
return nil, antonShape("state reward", fmt.Sprintf("phase=%d values=%d want=kind+4_ints", phaseIndex+1, len(tokens)))
}
rank, err := antonIntToken(tokens[1], "state reward rank")
if err != nil {
return nil, err
}
if rank < -1 {
return nil, antonInvalid("state reward rank", rank, "want>=-1")
}
weight, err := antonPositiveIntToken(tokens[2], "state reward weight")
if err != nil {
return nil, err
}
itemID, err := antonPositiveUint32Token(tokens[3], "state reward definition item id")
if err != nil {
return nil, err
}
flag, err := antonIntToken(tokens[4], "state reward flag")
if err != nil {
return nil, err
}
if flag != 0 && flag != 1 {
return nil, antonInvalid("state reward flag", flag, "want=0_or_1")
}
rewards = append(rewards, AntonStateReward{
Kind: tokens[0].Value, Rank: rank, Weight: weight, DefinitionItemID: itemID, Flag: byte(flag),
})
}
if len(rewards) == 0 {
return nil, antonMissing("state reward", fmt.Sprintf("phase=%d", phaseIndex+1))
}
return rewards, nil
}
func antonParseDungeon(document *dnfpvf.Document, block antonSectionBlock, phaseIndex, memberMax int) (AntonDungeonInfo, error) {
return antonParseDungeonWithPartyMax(document, block, phaseIndex, memberMax, false)
}
func antonParseDungeonWithPartyMax(document *dnfpvf.Document, block antonSectionBlock, phaseIndex, memberMax int, allowZeroPartyMax bool) (AntonDungeonInfo, error) {
if err := antonRequireEmptySection(document, block.open); err != nil {
return AntonDungeonInfo{}, err
}
if err := antonRequireEmptySection(document, block.close); err != nil {
return AntonDungeonInfo{}, err
}
start, end := block.open+1, block.close
indexSection, err := antonUniqueSection(document, "index", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
indexTokens, err := antonSectionTokens(document, indexSection)
if err != nil {
return AntonDungeonInfo{}, err
}
if len(indexTokens) != 1 {
return AntonDungeonInfo{}, antonShape("index", fmt.Sprintf("phase=%d values=%d want=1", phaseIndex+1, len(indexTokens)))
}
dungeonID, err := antonPositiveUint32Token(indexTokens[0], "dungeon index")
if err != nil {
return AntonDungeonInfo{}, err
}
initSection, err := antonUniqueSection(document, "init state", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
initTokens, err := antonSectionTokens(document, initSection)
if err != nil {
return AntonDungeonInfo{}, err
}
if len(initTokens) != 1 || initTokens[0].Kind != dnfpvf.TokenString {
return AntonDungeonInfo{}, antonShape("init state", fmt.Sprintf("dungeon=%d want=one string", dungeonID))
}
state := AntonDungeonState(strings.ToLower(strings.TrimSpace(initTokens[0].Value)))
if state != AntonDungeonOpen && state != AntonDungeonHide && state != AntonDungeonClosed {
return AntonDungeonInfo{}, antonInvalid("init state", initTokens[0].Value, fmt.Sprintf("dungeon=%d", dungeonID))
}
hpInfoSection, err := antonUniqueSection(document, "hp info", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
if err := antonRequireEmptySection(document, hpInfoSection); err != nil {
return AntonDungeonInfo{}, err
}
hpSectionName := fmt.Sprintf("%d dgn hp", dungeonID)
hpSection, err := antonUniqueSection(document, hpSectionName, start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
hp, err := antonPositiveIntAt(document, hpSection, hpSectionName)
if err != nil {
return AntonDungeonInfo{}, err
}
partyMaxSection, err := antonUniqueSection(document, "party max", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
partyMax, err := antonNonNegativeIntAt(document, partyMaxSection, "party max")
if err != nil {
return AntonDungeonInfo{}, err
}
if partyMax < 0 || (!allowZeroPartyMax && partyMax == 0) || partyMax > memberMax {
return AntonDungeonInfo{}, antonInvalid("party max", partyMax, fmt.Sprintf("dungeon=%d want<=member_max(%d)", dungeonID, memberMax))
}
if !(indexSection < initSection && initSection < hpInfoSection && hpInfoSection < hpSection && hpSection < partyMaxSection) {
return AntonDungeonInfo{}, antonShape("dungeon info", fmt.Sprintf("dungeon=%d fields_out_of_order", dungeonID))
}
var lockoutType [2]int
lockoutSection, hasLockout, err := antonOptionalUniqueSection(document, "lockout type", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
if hasLockout {
lockoutTokens, tokenErr := antonSectionTokens(document, lockoutSection)
if tokenErr != nil {
return AntonDungeonInfo{}, tokenErr
}
if len(lockoutTokens) != len(lockoutType) {
return AntonDungeonInfo{}, antonShape("lockout type",
fmt.Sprintf("dungeon=%d values=%d want=%d", dungeonID, len(lockoutTokens), len(lockoutType)))
}
for position, token := range lockoutTokens {
value, valueErr := antonIntToken(token, "lockout type")
if valueErr != nil {
return AntonDungeonInfo{}, valueErr
}
if value < 0 {
return AntonDungeonInfo{}, antonInvalid("lockout type", value, fmt.Sprintf("dungeon=%d want_nonnegative", dungeonID))
}
lockoutType[position] = value
}
}
// [DESTROY OBJECT INFO] has no closing tag, so its object-group markers are
// every remaining section of this [DUNGEON INFO] block. They carry no
// values; the name is the payload.
var destroyObjects []string
destroySection, hasDestroyObjects, err := antonOptionalUniqueSection(document, "destroy object info", start, end)
if err != nil {
return AntonDungeonInfo{}, err
}
strictEnd := end
if hasDestroyObjects {
if err := antonRequireEmptySection(document, destroySection); err != nil {
return AntonDungeonInfo{}, err
}
if destroySection < partyMaxSection {
return AntonDungeonInfo{}, antonShape("destroy object info",
fmt.Sprintf("dungeon=%d must follow party max", dungeonID))
}
strictEnd = destroySection
for sectionIndex := destroySection + 1; sectionIndex < end; sectionIndex++ {
if err := antonRequireEmptySection(document, sectionIndex); err != nil {
return AntonDungeonInfo{}, err
}
destroyObjects = append(destroyObjects, antonSectionName(document.Sections[sectionIndex].Name))
}
if len(destroyObjects) == 0 {
return AntonDungeonInfo{}, antonMissing("destroy object info", fmt.Sprintf("dungeon=%d object groups", dungeonID))
}
}
allowed := map[string]struct{}{
"index": {}, "init state": {}, "hp info": {}, hpSectionName: {}, "party max": {},
"lockout type": {},
}
for sectionIndex := start; sectionIndex < strictEnd; sectionIndex++ {
if _, ok := allowed[antonSectionName(document.Sections[sectionIndex].Name)]; !ok {
return AntonDungeonInfo{}, antonShape("dungeon info", fmt.Sprintf("dungeon=%d unexpected_section=%q", dungeonID, document.Sections[sectionIndex].Name))
}
}
return AntonDungeonInfo{
Index: dungeonID,
InitState: state,
HP: hp,
PartyMax: partyMax,
LockoutType: lockoutType,
DestroyObjects: destroyObjects,
}, nil
}
func antonFindBlocks(document *dnfpvf.Document, name string, start, end int) ([]antonSectionBlock, error) {
want := antonSectionName(name)
closeName := "/" + want
openIndex := -1
blocks := make([]antonSectionBlock, 0)
for index := start; index < end; index++ {
sectionName := antonSectionName(document.Sections[index].Name)
switch sectionName {
case want:
if openIndex >= 0 {
return nil, antonShape(name, fmt.Sprintf("nested_open_at=%d", index))
}
openIndex = index
case closeName:
if openIndex < 0 {
return nil, antonShape(name, fmt.Sprintf("unmatched_close_at=%d", index))
}
blocks = append(blocks, antonSectionBlock{open: openIndex, close: index})
openIndex = -1
}
}
if openIndex >= 0 {
return nil, antonShape(name, fmt.Sprintf("unclosed_open_at=%d", openIndex))
}
return blocks, nil
}
func antonReadPositiveIntSection(document *dnfpvf.Document, name string, start, end int) (int, error) {
sectionIndex, err := antonUniqueSection(document, name, start, end)
if err != nil {
return 0, err
}
return antonPositiveIntAt(document, sectionIndex, name)
}
func antonReadPositiveIntListSection(document *dnfpvf.Document, name string, start, end, want int) ([]int, error) {
sectionIndex, err := antonUniqueSection(document, name, start, end)
if err != nil {
return nil, err
}
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return nil, err
}
if len(tokens) != want {
return nil, antonShape(name, fmt.Sprintf("values=%d want=%d", len(tokens), want))
}
values := make([]int, 0, len(tokens))
for _, token := range tokens {
value, valueErr := antonPositiveIntToken(token, name)
if valueErr != nil {
return nil, valueErr
}
values = append(values, value)
}
return values, nil
}
func antonPositiveIntAt(document *dnfpvf.Document, sectionIndex int, field string) (int, error) {
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return 0, err
}
if len(tokens) != 1 {
return 0, antonShape(field, fmt.Sprintf("values=%d want=1", len(tokens)))
}
return antonPositiveIntToken(tokens[0], field)
}
func antonNonNegativeIntAt(document *dnfpvf.Document, sectionIndex int, field string) (int, error) {
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return 0, err
}
if len(tokens) != 1 {
return 0, antonShape(field, fmt.Sprintf("values=%d want=1", len(tokens)))
}
value, err := antonIntToken(tokens[0], field)
if err != nil {
return 0, err
}
if value < 0 {
return 0, antonInvalid(field, value, "want_nonnegative")
}
return value, nil
}
func antonUniqueSection(document *dnfpvf.Document, name string, start, end int) (int, error) {
found, ok, err := antonOptionalUniqueSection(document, name, start, end)
if err != nil {
return -1, err
}
if !ok {
return -1, antonMissing(name, "required")
}
return found, nil
}
func antonOptionalUniqueSection(document *dnfpvf.Document, name string, start, end int) (int, bool, error) {
want := antonSectionName(name)
found := -1
for index := start; index < end; index++ {
if antonSectionName(document.Sections[index].Name) != want {
continue
}
if found >= 0 {
return -1, false, antonShape(name, fmt.Sprintf("duplicate_at=%d,%d", found, index))
}
found = index
}
if found < 0 {
return -1, false, nil
}
return found, true, nil
}
func antonSectionTokens(document *dnfpvf.Document, sectionIndex int) ([]dnfpvf.Token, error) {
if sectionIndex < 0 || sectionIndex >= len(document.Sections) {
return nil, antonShape("section", fmt.Sprintf("index=%d sections=%d", sectionIndex, len(document.Sections)))
}
section := document.Sections[sectionIndex]
if section.Start < 0 || section.End < section.Start || section.End > len(document.Tokens) {
return nil, antonShape(section.Name, fmt.Sprintf("token_range=%d:%d tokens=%d", section.Start, section.End, len(document.Tokens)))
}
return document.Tokens[section.Start:section.End], nil
}
func antonRequireEmptySection(document *dnfpvf.Document, sectionIndex int) error {
tokens, err := antonSectionTokens(document, sectionIndex)
if err != nil {
return err
}
if len(tokens) != 0 {
return antonShape(document.Sections[sectionIndex].Name, fmt.Sprintf("values=%d want=0", len(tokens)))
}
return nil
}
func antonIntToken(token dnfpvf.Token, field string) (int, error) {
if token.Kind != dnfpvf.TokenInt || token.Int < math.MinInt32 || token.Int > math.MaxInt32 {
return 0, antonInvalid(field, token.Raw, "want=int32")
}
return int(token.Int), nil
}
func antonPositiveIntToken(token dnfpvf.Token, field string) (int, error) {
value, err := antonIntToken(token, field)
if err != nil {
return 0, err
}
if value <= 0 {
return 0, antonInvalid(field, value, "want_positive")
}
return value, nil
}
func antonPositiveUint32Token(token dnfpvf.Token, field string) (uint32, error) {
if token.Kind != dnfpvf.TokenInt || token.Int <= 0 || token.Int > math.MaxUint32 {
return 0, antonInvalid(field, token.Raw, "want=positive_uint32")
}
return uint32(token.Int), nil
}
func antonMissing(section, context string) error {
return fmt.Errorf("%w: path=%s section=%s context=%s", ErrAntonPVFSectionMissing, AntonPVFPath, section, context)
}
func antonShape(section, detail string) error {
return fmt.Errorf("%w: path=%s section=%s %s", ErrAntonPVFSectionShape, AntonPVFPath, section, detail)
}
func antonInvalid(field string, value any, detail string) error {
return fmt.Errorf("%w: path=%s field=%s value=%v %s", ErrAntonPVFValueInvalid, AntonPVFPath, field, value, detail)
}
func antonPathKey(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
value = strings.TrimLeft(value, "/")
return strings.ToLower(value)
}
func antonSectionName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}