服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
384 lines
15 KiB
Go
384 lines
15 KiB
Go
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
|
|
}
|