Files
90CNServer/server/internal/modules/dnf/adventuregroup/runtime_config.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

757 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}