服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
757 lines
26 KiB
Go
757 lines
26 KiB
Go
package adventuregroup
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
)
|
||
|
||
const (
|
||
ShopPointBrave = iota
|
||
ShopPointGlory
|
||
ShopPointPure
|
||
ShopPointTypeCount
|
||
)
|
||
|
||
type ShopProduct struct {
|
||
ItemID uint32
|
||
RequiredManageLevel int
|
||
Cost uint32
|
||
MonthlyPurchaseLimit uint32
|
||
ResetType int
|
||
}
|
||
|
||
type ShopCategory struct {
|
||
Index byte
|
||
MaxPoint uint32
|
||
ResetPointMonthly bool
|
||
ExperiencePerPoint uint64
|
||
PointPerExperience uint32
|
||
Products []ShopProduct
|
||
}
|
||
|
||
type CapsuleConfig struct {
|
||
MinimumExperience uint64
|
||
MaximumExperience uint64
|
||
MaximumCount uint32
|
||
MinimumLevel int
|
||
MaximumLevel int
|
||
GrantedExperience uint32
|
||
UpdateAt time.Time
|
||
}
|
||
|
||
// CapsuleTotalCapacity 返回成长胶囊槽的**总容量**。
|
||
//
|
||
// PVF 里的两个数是两件事,绝不能混:
|
||
//
|
||
// [capsule max exp] 116,914,950 = **一格**的容量(界面上的 100%)
|
||
// [capsule max count] 10 = 可以攒几格(界面写"累积数上限 : 10个")
|
||
//
|
||
// 所以总容量 = 116,914,950 × 10。把 `[capsule max exp]` 当成总上限会让累计值
|
||
// 卡死在正好 100% 上:客户端那条进度条的分子是 `累计值 mod [capsule max exp]`,
|
||
// 于是永远显示 0%、玩家看到的就是"胶囊不累计"(2026-09-23 线上现场)。
|
||
func CapsuleTotalCapacity(config CapsuleConfig) uint64 {
|
||
if config.MaximumExperience == 0 {
|
||
return 0
|
||
}
|
||
count := config.MaximumCount
|
||
if count == 0 {
|
||
count = 1
|
||
}
|
||
if config.MaximumExperience > math.MaxUint64/uint64(count) {
|
||
return math.MaxUint64
|
||
}
|
||
return config.MaximumExperience * uint64(count)
|
||
}
|
||
|
||
// CapsuleGaugeCapacity 返回一格(100%)的容量,也就是客户端进度条的分母。
|
||
func CapsuleGaugeCapacity(config CapsuleConfig) uint64 {
|
||
return config.MaximumExperience
|
||
}
|
||
|
||
// CapsuleClaimCount 返回"现在可以领取几次成长胶囊经验值"。
|
||
//
|
||
// 客户端自己的说明文字(Interface2/Adventurer2018/AdvShop 的
|
||
// `capsule_explain`)把规则写死了:
|
||
//
|
||
// 经验值按照10% : 1,000,000点的比例换算。
|
||
// (累积100%时, 可获得10,000,000点经验值)
|
||
// 角色Lv : Lv.50 ~ 86
|
||
// 累积数上限 : 10个
|
||
//
|
||
// 所以一次领取 = 消耗 `[capsule need minimum exp]`(= 一格 10%,
|
||
// 116,914,950 / 10 = 11,691,495)= 换到 `[capsule give exp]`(1,000,000),
|
||
// 可领取次数 = floor(累计经验 / 该档位),一格 = 10 次。
|
||
//
|
||
// 上限必须是"格数 × 每格档数"(10 × 10 = 100):客户端拿到这个数会**按它
|
||
// 建列表**(0x13F0FA0 里 `for (i = 0; i < count; i++)` 逐个拼字符串),
|
||
// 所以绝不能把"可领取的经验值总量"(1,000 万级)当成次数发出去 —— 那会让
|
||
// 客户端建上千万个字符串而卡死。2026-09-23 第一版就写错了,装机前发现并回滚。
|
||
func CapsuleClaimCount(config CapsuleConfig, accumulated uint64) uint32 {
|
||
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience {
|
||
return 0
|
||
}
|
||
chunks := accumulated / config.MinimumExperience
|
||
maximum := uint64(config.MaximumCount)
|
||
if maximum == 0 {
|
||
maximum = 1
|
||
}
|
||
if perGauge := config.MaximumExperience / config.MinimumExperience; perGauge > 0 {
|
||
if maximum > math.MaxUint32/perGauge {
|
||
maximum = math.MaxUint32
|
||
} else {
|
||
maximum *= perGauge
|
||
}
|
||
}
|
||
if chunks > maximum {
|
||
chunks = maximum
|
||
}
|
||
if chunks > math.MaxUint32 {
|
||
return math.MaxUint32
|
||
}
|
||
return uint32(chunks)
|
||
}
|
||
|
||
type ExpeditionArea struct {
|
||
Index byte
|
||
RequiredManageLevel int
|
||
RewardRates map[uint32]float64
|
||
DesignatedGroups []string
|
||
}
|
||
|
||
type RuntimeConfig struct {
|
||
ShopCategories []ShopCategory
|
||
Growth GrowthConfig
|
||
Capsule CapsuleConfig
|
||
ExpeditionAreas []ExpeditionArea
|
||
AttributeIDs map[string]byte
|
||
AttributeGroups map[string][]string
|
||
CharacterAttributes map[[2]byte][]string
|
||
AttributeRewardRates []float64
|
||
// PartyMemberRewardRates is `[party member rate]` (5 entries, indexed by
|
||
// member count) and ExpeditionExtraRate is `[extra rate]`. Both feed the
|
||
// native expedition reward formula.
|
||
PartyMemberRewardRates []float64
|
||
ExpeditionExtraRate float64
|
||
RotationDays int
|
||
professionClasses map[[2]byte]byte
|
||
professionGroupCount int
|
||
}
|
||
|
||
func (c RuntimeConfig) Clone() RuntimeConfig {
|
||
out := c
|
||
out.Growth.RequiredExperience = append([]uint64(nil), c.Growth.RequiredExperience...)
|
||
out.Growth.LevelBenefits = append([]Benefits(nil), c.Growth.LevelBenefits...)
|
||
out.ShopCategories = make([]ShopCategory, len(c.ShopCategories))
|
||
for i, category := range c.ShopCategories {
|
||
out.ShopCategories[i] = category
|
||
out.ShopCategories[i].Products = append([]ShopProduct(nil), category.Products...)
|
||
}
|
||
out.ExpeditionAreas = make([]ExpeditionArea, len(c.ExpeditionAreas))
|
||
for i, area := range c.ExpeditionAreas {
|
||
out.ExpeditionAreas[i] = area
|
||
out.ExpeditionAreas[i].RewardRates = make(map[uint32]float64, len(area.RewardRates))
|
||
for duration, rate := range area.RewardRates {
|
||
out.ExpeditionAreas[i].RewardRates[duration] = rate
|
||
}
|
||
out.ExpeditionAreas[i].DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
|
||
}
|
||
out.AttributeIDs = make(map[string]byte, len(c.AttributeIDs))
|
||
for key, value := range c.AttributeIDs {
|
||
out.AttributeIDs[key] = value
|
||
}
|
||
out.AttributeGroups = make(map[string][]string, len(c.AttributeGroups))
|
||
for key, values := range c.AttributeGroups {
|
||
out.AttributeGroups[key] = append([]string(nil), values...)
|
||
}
|
||
out.CharacterAttributes = make(map[[2]byte][]string, len(c.CharacterAttributes))
|
||
for key, values := range c.CharacterAttributes {
|
||
out.CharacterAttributes[key] = append([]string(nil), values...)
|
||
}
|
||
out.AttributeRewardRates = append([]float64(nil), c.AttributeRewardRates...)
|
||
out.PartyMemberRewardRates = append([]float64(nil), c.PartyMemberRewardRates...)
|
||
out.professionClasses = make(map[[2]byte]byte, len(c.professionClasses))
|
||
for key, value := range c.professionClasses {
|
||
out.professionClasses[key] = value
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ProfessionClassification resolves the ordered [expert list] group used by
|
||
// the current client collection UI for one job/base-grow slot.
|
||
func (c RuntimeConfig) ProfessionClassification(job, baseGrow int) (byte, bool) {
|
||
if job < 0 || job > math.MaxUint8 || baseGrow <= 0 || baseGrow > math.MaxUint8 {
|
||
return 0, false
|
||
}
|
||
value, found := c.professionClasses[[2]byte{byte(job), byte(baseGrow)}]
|
||
return value, found
|
||
}
|
||
|
||
func (c RuntimeConfig) ProfessionGroupCount() int {
|
||
return c.professionGroupCount
|
||
}
|
||
|
||
func (c RuntimeConfig) Shop(index byte) (ShopCategory, bool) {
|
||
for _, category := range c.ShopCategories {
|
||
if category.Index == index {
|
||
copy := category
|
||
copy.Products = append([]ShopProduct(nil), category.Products...)
|
||
return copy, true
|
||
}
|
||
}
|
||
return ShopCategory{}, false
|
||
}
|
||
|
||
func (c RuntimeConfig) Area(index byte) (ExpeditionArea, bool) {
|
||
for _, area := range c.ExpeditionAreas {
|
||
if area.Index == index {
|
||
copy := area
|
||
copy.DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
|
||
copy.RewardRates = make(map[uint32]float64, len(area.RewardRates))
|
||
for duration, rate := range area.RewardRates {
|
||
copy.RewardRates[duration] = rate
|
||
}
|
||
return copy, true
|
||
}
|
||
}
|
||
return ExpeditionArea{}, false
|
||
}
|
||
|
||
// AreaAttributes resolves the PVF rotation deterministically from the UTC+8
|
||
// calendar day. Group order comes from the PVF, and duplicate attributes are
|
||
// skipped just like the client's uniqueness presentation.
|
||
func (c RuntimeConfig) AreaAttributes(index byte, now time.Time) []byte {
|
||
area, ok := c.Area(index)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
rotationDays := c.RotationDays
|
||
if rotationDays <= 0 {
|
||
rotationDays = 1
|
||
}
|
||
local := now.In(adventureGroupCalendarLocation)
|
||
// local.Unix() is absolute UTC seconds, so dividing it by 86400 would roll
|
||
// the rotation over at UTC midnight. Add the zone offset to get local
|
||
// wall-clock seconds, which puts the boundary at local (UTC+8) midnight.
|
||
_, offsetSeconds := local.Zone()
|
||
day := int((local.Unix() + int64(offsetSeconds)) / 86400 / int64(rotationDays))
|
||
out := make([]byte, 0, len(area.DesignatedGroups))
|
||
seen := make(map[byte]struct{}, len(area.DesignatedGroups))
|
||
for offset, groupName := range area.DesignatedGroups {
|
||
group := c.AttributeGroups[normalizedPVFName(groupName)]
|
||
if len(group) == 0 {
|
||
if id, exists := c.AttributeIDs[normalizedPVFName(groupName)]; exists {
|
||
group = []string{groupName}
|
||
_ = id
|
||
}
|
||
}
|
||
if len(group) == 0 {
|
||
continue
|
||
}
|
||
for attempt := 0; attempt < len(group); attempt++ {
|
||
name := group[(day+int(index)+offset+attempt)%len(group)]
|
||
id, exists := c.AttributeIDs[normalizedPVFName(name)]
|
||
if !exists {
|
||
continue
|
||
}
|
||
if _, duplicate := seen[id]; duplicate {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
out = append(out, id)
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ExpeditionReward follows the native current-EXE formula at 0x38327F0.
|
||
//
|
||
// Character levels are NOT an input: the reward is the area/duration rate times
|
||
// the party-size rate times the attribute-match bonus, rounded to float32 and
|
||
// truncated, then scaled by the extra rate. designated is the area's rotated
|
||
// attribute set, supplied by the caller so the value the client was shown and
|
||
// the value the server pays come from the same snapshot.
|
||
func (c RuntimeConfig) ExpeditionReward(areaIndex byte, durationSeconds uint32, members []ExpeditionMemberInput, designated []byte) (uint32, bool) {
|
||
area, ok := c.Area(areaIndex)
|
||
if !ok || len(members) == 0 || len(members) > 4 ||
|
||
len(c.PartyMemberRewardRates) <= len(members) || c.ExpeditionExtraRate <= 0 {
|
||
return 0, false
|
||
}
|
||
rate, ok := area.RewardRates[durationSeconds]
|
||
if !ok || rate <= 0 {
|
||
return 0, false
|
||
}
|
||
partyAttributes := make(map[byte]struct{})
|
||
for _, member := range members {
|
||
if member.Level <= 0 {
|
||
return 0, false
|
||
}
|
||
attributes := c.CharacterAttributes[[2]byte{member.Job, member.GrowType & 0x0f}]
|
||
for _, name := range attributes {
|
||
if id, exists := c.AttributeIDs[normalizedPVFName(name)]; exists {
|
||
partyAttributes[id] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
matched := 0
|
||
for _, wanted := range designated {
|
||
if _, found := partyAttributes[wanted]; found {
|
||
matched++
|
||
}
|
||
}
|
||
if matched >= len(c.AttributeRewardRates) {
|
||
matched = len(c.AttributeRewardRates) - 1
|
||
}
|
||
bonus := 1.0
|
||
if matched >= 0 && len(c.AttributeRewardRates) > 0 {
|
||
bonus = c.AttributeRewardRates[matched]
|
||
}
|
||
// The current EXE rounds the float32 product, then truncates after the
|
||
// extra rate. Character levels are not an input to this formula.
|
||
product := float32(float32(rate) * float32(c.PartyMemberRewardRates[len(members)]))
|
||
product = float32(product * float32(bonus))
|
||
rounded := math.Trunc(float64(float32(product + 0.5)))
|
||
value := math.Trunc(float64(float32(float32(rounded) * float32(c.ExpeditionExtraRate))))
|
||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||
return 0, false
|
||
}
|
||
if value > math.MaxUint32 {
|
||
return math.MaxUint32, true
|
||
}
|
||
return uint32(value), true
|
||
}
|
||
|
||
type ExpeditionMemberInput struct {
|
||
Level int
|
||
Job byte
|
||
GrowType byte
|
||
}
|
||
|
||
func loadRuntimeConfig(ctx context.Context, source dnfpvf.Source) (RuntimeConfig, error) {
|
||
systemText, err := source.ReadText(AdventureSystem2018Path)
|
||
if err != nil {
|
||
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
|
||
}
|
||
systemDoc, err := dnfpvf.Parse(AdventureSystem2018Path, systemText)
|
||
if err != nil {
|
||
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
|
||
}
|
||
expeditionText, err := source.ReadText(ExpeditionSystemPath)
|
||
if err != nil {
|
||
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
|
||
}
|
||
expeditionDoc, err := dnfpvf.Parse(ExpeditionSystemPath, expeditionText)
|
||
if err != nil {
|
||
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
|
||
}
|
||
if ctx != nil {
|
||
if err := ctx.Err(); err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
}
|
||
shops, err := parseShopCategories(systemDoc)
|
||
if err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
growth, err := parseGrowthConfig(systemDoc)
|
||
if err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
capsule, err := parseCapsule(systemDoc)
|
||
if err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
areas, err := parseExpeditionAreas(expeditionDoc)
|
||
if err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
professionClasses, professionGroupCount, err := parseProfessionClassifications(systemDoc)
|
||
if err != nil {
|
||
return RuntimeConfig{}, err
|
||
}
|
||
config := RuntimeConfig{
|
||
ShopCategories: shops,
|
||
Growth: growth,
|
||
Capsule: capsule,
|
||
ExpeditionAreas: areas,
|
||
AttributeIDs: parseAttributeIDs(expeditionDoc),
|
||
AttributeGroups: parseAttributeGroups(expeditionDoc),
|
||
CharacterAttributes: parseCharacterAttributes(expeditionDoc),
|
||
RotationDays: int(firstSectionInt(expeditionDoc, "area attribute rotation period")),
|
||
professionClasses: professionClasses,
|
||
professionGroupCount: professionGroupCount,
|
||
}
|
||
config.AttributeRewardRates, err = sectionFloats(expeditionDoc, "attribute achieve rate")
|
||
if err != nil || len(config.AttributeRewardRates) == 0 {
|
||
return RuntimeConfig{}, fmt.Errorf("%w: %s [attribute achieve rate]", ErrTableMalformed, ExpeditionSystemPath)
|
||
}
|
||
config.PartyMemberRewardRates, err = sectionFloats(expeditionDoc, "party member rate")
|
||
if err != nil || len(config.PartyMemberRewardRates) != 5 {
|
||
return RuntimeConfig{}, fmt.Errorf("%w: %s [party member rate]", ErrTableMalformed, ExpeditionSystemPath)
|
||
}
|
||
extraRates, err := sectionFloats(expeditionDoc, "extra rate")
|
||
if err != nil || len(extraRates) != 1 {
|
||
return RuntimeConfig{}, fmt.Errorf("%w: %s [extra rate]", ErrTableMalformed, ExpeditionSystemPath)
|
||
}
|
||
config.ExpeditionExtraRate = extraRates[0]
|
||
if len(config.AttributeIDs) == 0 || len(config.AttributeGroups) == 0 || len(config.CharacterAttributes) == 0 {
|
||
return RuntimeConfig{}, fmt.Errorf("%w: %s attribute configuration", ErrTableMalformed, ExpeditionSystemPath)
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
var adventureProfessionJobIDs = map[string]byte{
|
||
"[swordman]": 0,
|
||
"[fighter]": 1,
|
||
"[gunner]": 2,
|
||
"[mage]": 3,
|
||
"[priest]": 4,
|
||
"[at gunner]": 5,
|
||
"[thief]": 6,
|
||
"[at fighter]": 7,
|
||
"[at mage]": 8,
|
||
"[demonic swordman]": 9,
|
||
"[creator mage]": 10,
|
||
"[at swordman]": 11,
|
||
"[knight]": 12,
|
||
"[demonic lancer]": 13,
|
||
"[at priest]": 14,
|
||
"[gun blader]": 15,
|
||
}
|
||
|
||
func parseProfessionClassifications(doc *dnfpvf.Document) (map[[2]byte]byte, int, error) {
|
||
result := make(map[[2]byte]byte)
|
||
if doc == nil {
|
||
return result, 0, nil
|
||
}
|
||
insideList := false
|
||
currentGroup := -1
|
||
groupCount := 0
|
||
infoCount := 0
|
||
for _, section := range doc.Sections {
|
||
switch normalizedPVFName(section.Name) {
|
||
case "expert list":
|
||
insideList = true
|
||
currentGroup = -1
|
||
case "/expert list":
|
||
if !insideList {
|
||
continue
|
||
}
|
||
if currentGroup >= 0 && infoCount == 0 {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
|
||
}
|
||
// The group count bounds the profession classification space, so it
|
||
// is checked against ProfessionTypeCount (the 20-row descriptor
|
||
// block), NOT ContentTypeCount (the 6 u32 content counters).
|
||
if groupCount == 0 || groupCount > ProfessionTypeCount {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] groups=%d", ErrTableMalformed, AdventureSystem2018Path, groupCount)
|
||
}
|
||
return result, groupCount, nil
|
||
case "group":
|
||
if !insideList {
|
||
continue
|
||
}
|
||
if currentGroup >= 0 && infoCount == 0 {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
|
||
}
|
||
tokens := sectionTokens(doc, section)
|
||
ordinal := int(firstInt(tokens))
|
||
if ordinal != groupCount || ordinal > math.MaxUint8 {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] group ordinal=%d want=%d", ErrTableMalformed, AdventureSystem2018Path, ordinal, groupCount)
|
||
}
|
||
currentGroup = ordinal
|
||
groupCount++
|
||
infoCount = 0
|
||
case "info":
|
||
if !insideList || currentGroup < 0 {
|
||
continue
|
||
}
|
||
tokens := sectionTokens(doc, section)
|
||
if len(tokens) != 3 || tokens[0].Kind != dnfpvf.TokenString ||
|
||
tokens[1].Kind != dnfpvf.TokenString || tokens[2].Kind != dnfpvf.TokenInt {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d malformed [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
|
||
}
|
||
job, found := adventureProfessionJobIDs[normalizedPVFName(tokens[0].Value)]
|
||
awakening := normalizedPVFName(tokens[1].Value)
|
||
grow := tokens[2].Int
|
||
if !found || (awakening != "second awakening" && awakening != "second realization") ||
|
||
grow <= 0 || grow > math.MaxUint8 {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d invalid [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
|
||
}
|
||
key := [2]byte{job, byte(grow)}
|
||
if _, duplicate := result[key]; duplicate {
|
||
return nil, 0, fmt.Errorf("%w: %s [expert list] duplicate job=%d grow=%d", ErrTableMalformed, AdventureSystem2018Path, job, grow)
|
||
}
|
||
result[key] = byte(currentGroup)
|
||
infoCount++
|
||
}
|
||
}
|
||
// Synthetic progression tables may omit [expert list]. Production uses the
|
||
// current real PVF and therefore exposes the mapping to representative flows.
|
||
if !insideList {
|
||
return result, 0, nil
|
||
}
|
||
return nil, 0, fmt.Errorf("%w: %s unterminated [expert list]", ErrTableMalformed, AdventureSystem2018Path)
|
||
}
|
||
|
||
func parseShopCategories(doc *dnfpvf.Document) ([]ShopCategory, error) {
|
||
var categories []ShopCategory
|
||
for index := 0; index < len(doc.Sections); index++ {
|
||
if !strings.EqualFold(strings.TrimSpace(doc.Sections[index].Name), "shop") {
|
||
continue
|
||
}
|
||
end := index + 1
|
||
for end < len(doc.Sections) && !strings.EqualFold(strings.TrimSpace(doc.Sections[end].Name), "/shop") {
|
||
end++
|
||
}
|
||
category := ShopCategory{}
|
||
for cursor := index + 1; cursor < end; cursor++ {
|
||
section := doc.Sections[cursor]
|
||
tokens := sectionTokens(doc, section)
|
||
switch normalizedPVFName(section.Name) {
|
||
case "index":
|
||
category.Index = byte(firstInt(tokens))
|
||
case "max shop point":
|
||
category.MaxPoint = uint32(firstInt(tokens))
|
||
case "reset point":
|
||
category.ResetPointMonthly = true
|
||
case "exp to get shop point":
|
||
values := ints(tokens)
|
||
if len(values) == 2 && values[0] > 0 && values[1] > 0 {
|
||
category.ExperiencePerPoint = uint64(values[0])
|
||
category.PointPerExperience = uint32(values[1])
|
||
}
|
||
case "adventurer shop purchase info":
|
||
values := ints(tokens)
|
||
if len(values)%5 != 0 {
|
||
return nil, fmt.Errorf("%w: %s shop=%d purchase integers=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, len(values))
|
||
}
|
||
for offset := 0; offset < len(values); offset += 5 {
|
||
if values[offset] <= 0 || values[offset+1] < 0 || values[offset+2] <= 0 || values[offset+3] <= 0 {
|
||
return nil, fmt.Errorf("%w: %s shop=%d purchase row=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, offset/5)
|
||
}
|
||
category.Products = append(category.Products, ShopProduct{
|
||
ItemID: uint32(values[offset]),
|
||
RequiredManageLevel: int(values[offset+1]),
|
||
Cost: uint32(values[offset+2]),
|
||
MonthlyPurchaseLimit: uint32(values[offset+3]),
|
||
ResetType: int(values[offset+4]),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if len(category.Products) > 0 {
|
||
categories = append(categories, category)
|
||
}
|
||
index = end
|
||
}
|
||
sort.Slice(categories, func(i, j int) bool { return categories[i].Index < categories[j].Index })
|
||
if len(categories) < ShopPointTypeCount {
|
||
return nil, fmt.Errorf("%w: %s shop categories=%d", ErrTableEmpty, AdventureSystem2018Path, len(categories))
|
||
}
|
||
return categories, nil
|
||
}
|
||
|
||
func parseCapsule(doc *dnfpvf.Document) (CapsuleConfig, error) {
|
||
config := CapsuleConfig{
|
||
MinimumExperience: uint64(firstSectionInt(doc, "capsule need minimum exp")),
|
||
MaximumExperience: uint64(firstSectionInt(doc, "capsule max exp")),
|
||
MaximumCount: uint32(firstSectionInt(doc, "capsule max count")),
|
||
MinimumLevel: int(firstSectionInt(doc, "capsule limit level min")),
|
||
MaximumLevel: int(firstSectionInt(doc, "capsule limit level max")),
|
||
GrantedExperience: uint32(firstSectionInt(doc, "capsule give exp")),
|
||
}
|
||
tokens, _ := doc.Section("capsule update date")
|
||
if len(tokens) == 1 && tokens[0].Kind == dnfpvf.TokenString {
|
||
config.UpdateAt, _ = time.ParseInLocation("2006-01-02 15:04:05", tokens[0].Value, adventureGroupCalendarLocation)
|
||
}
|
||
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience ||
|
||
config.MaximumCount == 0 || config.MinimumLevel <= 0 || config.MaximumLevel < config.MinimumLevel ||
|
||
config.GrantedExperience == 0 {
|
||
return CapsuleConfig{}, fmt.Errorf("%w: %s capsule configuration", ErrTableMalformed, AdventureSystem2018Path)
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func parseExpeditionAreas(doc *dnfpvf.Document) ([]ExpeditionArea, error) {
|
||
var areas []ExpeditionArea
|
||
for index := 0; index < len(doc.Sections); index++ {
|
||
if normalizedPVFName(doc.Sections[index].Name) != "area" {
|
||
continue
|
||
}
|
||
end := index + 1
|
||
for end < len(doc.Sections) && normalizedPVFName(doc.Sections[end].Name) != "/area" {
|
||
end++
|
||
}
|
||
area := ExpeditionArea{RewardRates: make(map[uint32]float64)}
|
||
for cursor := index + 1; cursor < end; cursor++ {
|
||
section := doc.Sections[cursor]
|
||
tokens := sectionTokens(doc, section)
|
||
switch normalizedPVFName(section.Name) {
|
||
case "index":
|
||
area.Index = byte(firstInt(tokens))
|
||
case "required level":
|
||
area.RequiredManageLevel = int(firstInt(tokens))
|
||
case "designated attribute":
|
||
for _, token := range tokens {
|
||
if token.Kind == dnfpvf.TokenString {
|
||
area.DesignatedGroups = append(area.DesignatedGroups, token.Value)
|
||
}
|
||
}
|
||
case "reward info":
|
||
if len(tokens)%2 != 0 {
|
||
return nil, fmt.Errorf("%w: %s area=%d reward tokens=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, len(tokens))
|
||
}
|
||
for offset := 0; offset < len(tokens); offset += 2 {
|
||
hours, rate := tokenNumber(tokens[offset]), tokenNumber(tokens[offset+1])
|
||
if hours <= 0 || rate <= 0 {
|
||
return nil, fmt.Errorf("%w: %s area=%d reward row=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, offset/2)
|
||
}
|
||
area.RewardRates[uint32(hours*3600)] = rate
|
||
}
|
||
}
|
||
}
|
||
if area.Index > 0 && area.RequiredManageLevel > 0 && len(area.RewardRates) > 0 {
|
||
areas = append(areas, area)
|
||
}
|
||
index = end
|
||
}
|
||
sort.Slice(areas, func(i, j int) bool { return areas[i].Index < areas[j].Index })
|
||
if len(areas) == 0 {
|
||
return nil, fmt.Errorf("%w: %s expedition areas", ErrTableEmpty, ExpeditionSystemPath)
|
||
}
|
||
return areas, nil
|
||
}
|
||
|
||
func parseAttributeIDs(doc *dnfpvf.Document) map[string]byte {
|
||
out := make(map[string]byte)
|
||
tokens, _ := doc.Section("attribute data")
|
||
for index := 0; index+1 < len(tokens); {
|
||
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenString {
|
||
index++
|
||
continue
|
||
}
|
||
id := tokens[index].Int
|
||
name := normalizedPVFName(tokens[index+1].Value)
|
||
if id > 0 && id <= math.MaxUint8 && id != math.MaxUint8 {
|
||
out[name] = byte(id)
|
||
}
|
||
index += 2
|
||
for index < len(tokens) && tokens[index].Kind != dnfpvf.TokenInt {
|
||
index++
|
||
}
|
||
if index < len(tokens) {
|
||
index++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseAttributeGroups(doc *dnfpvf.Document) map[string][]string {
|
||
out := make(map[string][]string)
|
||
for _, section := range doc.Sections {
|
||
if normalizedPVFName(section.Name) != "group" {
|
||
continue
|
||
}
|
||
tokens := sectionTokens(doc, section)
|
||
if len(tokens) < 2 || tokens[0].Kind != dnfpvf.TokenString {
|
||
continue
|
||
}
|
||
key := normalizedPVFName(tokens[0].Value)
|
||
for _, token := range tokens[1:] {
|
||
if token.Kind == dnfpvf.TokenString {
|
||
out[key] = append(out[key], token.Value)
|
||
}
|
||
}
|
||
}
|
||
if groupC := out[normalizedPVFName("[group C]")]; len(groupC) > 0 {
|
||
out[normalizedPVFName("[GROUP C ALLOW]")] = append([]string(nil), groupC...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseCharacterAttributes(doc *dnfpvf.Document) map[[2]byte][]string {
|
||
out := make(map[[2]byte][]string)
|
||
tokens, _ := doc.Section("character attribute")
|
||
for index := 0; index+5 < len(tokens); index += 6 {
|
||
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenInt {
|
||
continue
|
||
}
|
||
key := [2]byte{byte(tokens[index].Int), byte(tokens[index+1].Int)}
|
||
for _, token := range tokens[index+3 : index+6] {
|
||
if token.Kind == dnfpvf.TokenString {
|
||
out[key] = append(out[key], token.Value)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func sectionTokens(doc *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
|
||
if doc == nil || section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
|
||
return nil
|
||
}
|
||
return doc.Tokens[section.Start:section.End]
|
||
}
|
||
|
||
func firstSectionInt(doc *dnfpvf.Document, name string) int64 {
|
||
tokens, _ := doc.Section(name)
|
||
return firstInt(tokens)
|
||
}
|
||
|
||
func firstInt(tokens []dnfpvf.Token) int64 {
|
||
for _, token := range tokens {
|
||
if token.Kind == dnfpvf.TokenInt {
|
||
return token.Int
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func ints(tokens []dnfpvf.Token) []int64 {
|
||
values := make([]int64, 0, len(tokens))
|
||
for _, token := range tokens {
|
||
if token.Kind == dnfpvf.TokenInt {
|
||
values = append(values, token.Int)
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
func sectionFloats(doc *dnfpvf.Document, name string) ([]float64, error) {
|
||
tokens, ok := doc.Section(name)
|
||
if !ok {
|
||
return nil, ErrTableEmpty
|
||
}
|
||
values := make([]float64, 0, len(tokens))
|
||
for _, token := range tokens {
|
||
value := tokenNumber(token)
|
||
if value <= 0 {
|
||
return nil, ErrTableMalformed
|
||
}
|
||
values = append(values, value)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
func tokenNumber(token dnfpvf.Token) float64 {
|
||
switch token.Kind {
|
||
case dnfpvf.TokenInt:
|
||
return float64(token.Int)
|
||
case dnfpvf.TokenFloat:
|
||
return token.Float
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func normalizedPVFName(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|