DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,854 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const (
|
||||
EnchanterType byte = 1
|
||||
AlchemistType byte = 2
|
||||
DisjointerType byte = 3
|
||||
DollControllerType byte = 4
|
||||
|
||||
EnchanterPVFPath = "character/expertjob/enchanter.exj"
|
||||
AlchemistPVFPath = "character/expertjob/alchemist.exj"
|
||||
DisjointerPVFPath = "character/expertjob/disjointer.exj"
|
||||
DollControllerPVFPath = "character/expertjob/doll_controller.exj"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCatalogUnavailable = errors.New("expert job catalog is unavailable")
|
||||
ErrJobUnsupported = errors.New("expert job is not supported")
|
||||
ErrRecipeUnavailable = errors.New("expert job recipe is unavailable")
|
||||
ErrLevelTooLow = errors.New("expert job level is too low")
|
||||
ErrExtractorInvalid = errors.New("expert job extractor is invalid")
|
||||
ErrExtractionInvalid = errors.New("expert job extraction target is invalid")
|
||||
ErrMachineEndurance = errors.New("expert job machine has no endurance")
|
||||
ErrMachineInvalid = errors.New("expert job machine state is invalid")
|
||||
ErrMachineGradeTooLow = errors.New("expert job machine grade is too low")
|
||||
ErrInsufficientGold = errors.New("expert job gold is insufficient")
|
||||
)
|
||||
|
||||
type randomSource interface {
|
||||
Intn(int) int
|
||||
}
|
||||
|
||||
type RandomFunc func(int) int
|
||||
|
||||
func (f RandomFunc) Intn(n int) int {
|
||||
if n <= 1 || f == nil {
|
||||
return 0
|
||||
}
|
||||
return f(n)
|
||||
}
|
||||
|
||||
type itemKind byte
|
||||
|
||||
const (
|
||||
itemStackable itemKind = iota + 1
|
||||
itemEquipment
|
||||
)
|
||||
|
||||
type itemReference struct {
|
||||
kind itemKind
|
||||
path string
|
||||
}
|
||||
|
||||
type RecipeEntry struct {
|
||||
ItemID int64
|
||||
Count int64
|
||||
}
|
||||
|
||||
type Recipe struct {
|
||||
RecipeItemID int64
|
||||
ProductItemID int64
|
||||
RequiredLevel int
|
||||
MinimumExperienceGain int
|
||||
MaximumExperienceGain int
|
||||
Materials []RecipeEntry
|
||||
Output RecipeEntry
|
||||
GoldCost int64
|
||||
}
|
||||
|
||||
type CompoundRate struct {
|
||||
MaximumLevelDifference int
|
||||
SuccessRatePercent int
|
||||
MinimumExperienceGain int
|
||||
MaximumExperienceGain int
|
||||
}
|
||||
|
||||
type Extractor struct {
|
||||
ItemID int64
|
||||
RequiredLevel int
|
||||
MinimumExperienceGain int
|
||||
MaximumExperienceGain int
|
||||
}
|
||||
|
||||
type extractionRuleKey struct {
|
||||
extractorID int64
|
||||
rarity int
|
||||
state int
|
||||
}
|
||||
|
||||
type ExtractionRule struct {
|
||||
ResultItemID int64
|
||||
Multiplier float64
|
||||
AdditionalTable int
|
||||
BigWinTable int
|
||||
BigWinChancePercent int
|
||||
}
|
||||
|
||||
type SelectionRule struct {
|
||||
MinimumLevel int
|
||||
MaximumLevel int
|
||||
ItemID int64
|
||||
Weight int
|
||||
QuantityMultiplier float64
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
JobType byte
|
||||
ExperienceThresholds []int64
|
||||
GiveUpCosts []int64
|
||||
AutoLearnRecipes map[int]int64
|
||||
Skills map[int]int
|
||||
CompoundRates []CompoundRate
|
||||
Recipes map[int64]Recipe
|
||||
Extractors map[int64]Extractor
|
||||
ExtractionRules map[extractionRuleKey]ExtractionRule
|
||||
AdditionalResults map[int][]SelectionRule
|
||||
BigWinResults map[int][]SelectionRule
|
||||
// ExtractionBaseConst selects the enchanter sell-price formula when it is
|
||||
// positive. Alchemist and doll-controller extraction use equipment grade.
|
||||
ExtractionBaseConst float64
|
||||
}
|
||||
|
||||
func (c *Config) Level(experience int64) int {
|
||||
if c == nil || len(c.ExperienceThresholds) == 0 {
|
||||
return 0
|
||||
}
|
||||
level := 1
|
||||
for _, threshold := range c.ExperienceThresholds {
|
||||
if experience < threshold {
|
||||
break
|
||||
}
|
||||
level++
|
||||
}
|
||||
if level > len(c.ExperienceThresholds) {
|
||||
level = len(c.ExperienceThresholds)
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func (c *Config) AutoRecipeIDs(experience int64) []int64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
level := c.Level(experience)
|
||||
type row struct {
|
||||
level int
|
||||
id int64
|
||||
}
|
||||
rows := make([]row, 0, len(c.AutoLearnRecipes))
|
||||
for required, id := range c.AutoLearnRecipes {
|
||||
if required <= level {
|
||||
rows = append(rows, row{required, id})
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].level != rows[j].level {
|
||||
return rows[i].level < rows[j].level
|
||||
}
|
||||
return rows[i].id < rows[j].id
|
||||
})
|
||||
out := make([]int64, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) CanLearn(experience int64, recipeID int64) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
recipe, ok := c.Recipes[recipeID]
|
||||
return ok && recipe.RequiredLevel <= c.Level(experience)+2
|
||||
}
|
||||
|
||||
func (c *Config) compoundRate(recipeLevel, jobLevel int) CompoundRate {
|
||||
if len(c.CompoundRates) == 0 {
|
||||
return CompoundRate{SuccessRatePercent: 100}
|
||||
}
|
||||
difference := recipeLevel - jobLevel
|
||||
for _, rate := range c.CompoundRates {
|
||||
if rate.MaximumLevelDifference >= difference {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
return c.CompoundRates[len(c.CompoundRates)-1]
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
source dnfpvf.Source
|
||||
items map[int64]itemReference
|
||||
configs map[byte]*Config
|
||||
equipmentSellRatePermille int64
|
||||
enchanter *EnchanterConfig
|
||||
disjointer *DisjointerConfig
|
||||
}
|
||||
|
||||
func Load(ctx context.Context, source dnfpvf.Source) (*Catalog, error) {
|
||||
if source == nil {
|
||||
return nil, ErrCatalogUnavailable
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
stackables, err := loadItemList(source, "stackable/stackable.lst", itemStackable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
equipment, err := loadItemList(source, "equipment/equipment.lst", itemEquipment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, ref := range equipment {
|
||||
if _, ok := stackables[id]; !ok {
|
||||
stackables[id] = ref
|
||||
}
|
||||
}
|
||||
catalog := &Catalog{source: source, items: stackables, configs: make(map[byte]*Config, 3)}
|
||||
if err := catalog.loadEquipmentSellRate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, input := range []struct {
|
||||
typ byte
|
||||
path, name, extraction string
|
||||
}{
|
||||
{EnchanterType, EnchanterPVFPath, "enchanter", "enchanter extraction"},
|
||||
{AlchemistType, AlchemistPVFPath, "alchemist", "alchemist extraction"},
|
||||
{DollControllerType, DollControllerPVFPath, "doll_controller", "doll_controller extraction"},
|
||||
} {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := catalog.loadConfig(input.typ, input.path, input.name, input.extraction)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load expert job type=%d: %w", input.typ, err)
|
||||
}
|
||||
catalog.configs[input.typ] = config
|
||||
}
|
||||
if catalog.enchanter, err = catalog.loadEnchanterConfig(catalog.configs[EnchanterType]); err != nil {
|
||||
return nil, fmt.Errorf("load enchanter config: %w", err)
|
||||
}
|
||||
if catalog.disjointer, err = catalog.loadDisjointerConfig(); err != nil {
|
||||
return nil, fmt.Errorf("load disjointer config: %w", err)
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Config(jobType byte) (*Config, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
config, ok := c.configs[jobType]
|
||||
return config, ok
|
||||
}
|
||||
|
||||
func (c *Catalog) RecipeJob(recipeID int64) (byte, Recipe, bool) {
|
||||
if c == nil {
|
||||
return 0, Recipe{}, false
|
||||
}
|
||||
if c.enchanter != nil {
|
||||
if recipe, ok := c.enchanter.CardRecipes[recipeID]; ok {
|
||||
return EnchanterType, Recipe{RequiredLevel: recipe.RequiredLevel, Materials: append([]RecipeEntry(nil), recipe.Materials...)}, true
|
||||
}
|
||||
}
|
||||
for _, jobType := range []byte{EnchanterType, AlchemistType, DollControllerType} {
|
||||
config := c.configs[jobType]
|
||||
if config == nil {
|
||||
continue
|
||||
}
|
||||
if recipe, ok := config.Recipes[recipeID]; ok {
|
||||
return jobType, recipe, true
|
||||
}
|
||||
}
|
||||
return 0, Recipe{}, false
|
||||
}
|
||||
|
||||
func (c *Catalog) Enchanter() (*EnchanterConfig, bool) {
|
||||
if c == nil || c.enchanter == nil {
|
||||
return nil, false
|
||||
}
|
||||
return c.enchanter, true
|
||||
}
|
||||
|
||||
func (c *Catalog) Disjointer() (*DisjointerConfig, bool) {
|
||||
if c == nil || c.disjointer == nil {
|
||||
return nil, false
|
||||
}
|
||||
return c.disjointer, true
|
||||
}
|
||||
|
||||
// GiveUpCosts returns the PVF-defined escalating abandonment costs for one
|
||||
// expert job. The returned slice is detached from the immutable catalog.
|
||||
func (c *Catalog) GiveUpCosts(jobType byte) ([]int64, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
if jobType == DisjointerType {
|
||||
if c.disjointer == nil || len(c.disjointer.GiveUpCosts) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return append([]int64(nil), c.disjointer.GiveUpCosts...), true
|
||||
}
|
||||
config := c.configs[jobType]
|
||||
if config == nil || len(config.GiveUpCosts) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return append([]int64(nil), config.GiveUpCosts...), true
|
||||
}
|
||||
|
||||
type CompoundPlan struct {
|
||||
AttemptedOutputs []RecipeEntry
|
||||
Rewards []RecipeEntry
|
||||
Materials []RecipeEntry
|
||||
GoldCost int64
|
||||
SuccessCount int
|
||||
FailureCount int
|
||||
ExperienceGain int64
|
||||
FinalExperience int64
|
||||
LevelChanged bool
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanCompound(jobType byte, experience int64, recipeID int64, count int, rng RandomFunc) (CompoundPlan, error) {
|
||||
return c.PlanCompoundWithLearned(jobType, experience, recipeID, count, false, rng)
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanCompoundWithLearned(jobType byte, experience int64, recipeID int64, count int, learned bool, rng RandomFunc) (CompoundPlan, error) {
|
||||
config, ok := c.Config(jobType)
|
||||
if !ok {
|
||||
return CompoundPlan{}, ErrJobUnsupported
|
||||
}
|
||||
recipe, ok := config.Recipes[recipeID]
|
||||
if !ok || count <= 0 || count > math.MaxUint16 {
|
||||
return CompoundPlan{}, ErrRecipeUnavailable
|
||||
}
|
||||
hasRecipe := learned
|
||||
for _, id := range config.AutoRecipeIDs(experience) {
|
||||
if id == recipeID {
|
||||
hasRecipe = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRecipe {
|
||||
return CompoundPlan{}, ErrRecipeUnavailable
|
||||
}
|
||||
level := config.Level(experience)
|
||||
if len(config.CompoundRates) == 0 && level < recipe.RequiredLevel {
|
||||
return CompoundPlan{}, ErrLevelTooLow
|
||||
}
|
||||
plan := CompoundPlan{
|
||||
AttemptedOutputs: []RecipeEntry{{ItemID: recipe.Output.ItemID, Count: recipe.Output.Count * int64(count)}},
|
||||
Materials: multiplyEntries(recipe.Materials, int64(count)),
|
||||
GoldCost: recipe.GoldCost * int64(count),
|
||||
}
|
||||
rate := config.compoundRate(recipe.RequiredLevel, level)
|
||||
if len(config.CompoundRates) == 0 {
|
||||
rate.MinimumExperienceGain = recipe.MinimumExperienceGain
|
||||
rate.MaximumExperienceGain = recipe.MaximumExperienceGain
|
||||
}
|
||||
random := randomSource(RandomFunc(rng))
|
||||
for attempt := 0; attempt < count; attempt++ {
|
||||
if random.Intn(100) >= rate.SuccessRatePercent {
|
||||
continue
|
||||
}
|
||||
plan.SuccessCount++
|
||||
gain := rate.MinimumExperienceGain
|
||||
if rate.MaximumExperienceGain > gain {
|
||||
gain += random.Intn(rate.MaximumExperienceGain - gain + 1)
|
||||
}
|
||||
plan.ExperienceGain += int64(gain)
|
||||
}
|
||||
plan.FailureCount = count - plan.SuccessCount
|
||||
if plan.SuccessCount > 0 {
|
||||
plan.Rewards = []RecipeEntry{{ItemID: recipe.Output.ItemID, Count: recipe.Output.Count * int64(plan.SuccessCount)}}
|
||||
}
|
||||
plan.FinalExperience = saturatingAdd(experience, plan.ExperienceGain)
|
||||
plan.LevelChanged = config.Level(experience) != config.Level(plan.FinalExperience)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type EquipmentMetadata struct {
|
||||
Rarity, Level, Grade, State int
|
||||
SellGold int64
|
||||
AttachType string
|
||||
DisjointForbidden bool
|
||||
}
|
||||
|
||||
func (c *Catalog) Equipment(itemID int64) (EquipmentMetadata, error) {
|
||||
ref, ok := c.items[itemID]
|
||||
if !ok || ref.kind != itemEquipment {
|
||||
return EquipmentMetadata{}, ErrExtractionInvalid
|
||||
}
|
||||
doc, err := c.itemDocument(ref)
|
||||
if err != nil {
|
||||
return EquipmentMetadata{}, err
|
||||
}
|
||||
rarity, rarityOK := doc.Int("rarity")
|
||||
level, levelOK := doc.Int("minimum level")
|
||||
if !rarityOK || !levelOK || rarity < 0 || rarity > 6 || level < 0 || level > math.MaxInt32 {
|
||||
return EquipmentMetadata{}, ErrExtractionInvalid
|
||||
}
|
||||
metadata := EquipmentMetadata{
|
||||
Rarity: int(rarity),
|
||||
Level: int(level),
|
||||
Grade: int(level),
|
||||
AttachType: normalizeTag(firstText(doc, "attach type")),
|
||||
}
|
||||
if grade, ok := doc.Int("grade"); ok && grade >= 0 && grade <= math.MaxInt32 {
|
||||
metadata.Grade = int(grade)
|
||||
}
|
||||
base, baseFound := doc.Int("value")
|
||||
if !baseFound || base < 0 {
|
||||
base, baseFound = doc.Int("price")
|
||||
}
|
||||
if !baseFound || base < 0 || c.equipmentSellRatePermille <= 0 || base > math.MaxInt64/c.equipmentSellRatePermille {
|
||||
return EquipmentMetadata{}, ErrExtractionInvalid
|
||||
}
|
||||
metadata.SellGold = base * c.equipmentSellRatePermille / 1000
|
||||
if metadata.SellGold < 1 {
|
||||
metadata.SellGold = 1
|
||||
}
|
||||
for _, content := range doc.Texts("impossible content") {
|
||||
if normalizeTag(content) == "disjoint" {
|
||||
metadata.DisjointForbidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
type ExtractionPlan struct {
|
||||
Materials []RecipeEntry
|
||||
ExperienceGain int64
|
||||
FinalExperience int64
|
||||
LevelChanged bool
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanExtraction(jobType byte, experience, extractorID int64, equipment EquipmentMetadata, rng RandomFunc) (ExtractionPlan, error) {
|
||||
config, ok := c.Config(jobType)
|
||||
if !ok {
|
||||
return ExtractionPlan{}, ErrJobUnsupported
|
||||
}
|
||||
extractor, ok := config.Extractors[extractorID]
|
||||
if !ok || config.Level(experience) < extractor.RequiredLevel {
|
||||
return ExtractionPlan{}, ErrExtractorInvalid
|
||||
}
|
||||
rule, ok := config.ExtractionRules[extractionRuleKey{extractorID, equipment.Rarity, equipment.State}]
|
||||
if !ok {
|
||||
return ExtractionPlan{}, ErrExtractionInvalid
|
||||
}
|
||||
random := randomSource(RandomFunc(rng))
|
||||
equipmentGrade := equipment.Grade
|
||||
if equipmentGrade <= 0 {
|
||||
equipmentGrade = equipment.Level
|
||||
}
|
||||
baseInput := float64(max(1, equipmentGrade))
|
||||
if config.ExtractionBaseConst > 0 {
|
||||
baseInput = float64(max(int64(1), equipment.SellGold)) / config.ExtractionBaseConst
|
||||
}
|
||||
base := int64(math.Floor(baseInput * rule.Multiplier))
|
||||
if base < 1 {
|
||||
base = 1
|
||||
}
|
||||
plan := ExtractionPlan{Materials: []RecipeEntry{{ItemID: rule.ResultItemID, Count: base}}}
|
||||
bigWin := random.Intn(10000) < max(0, min(100, rule.BigWinChancePercent))*100
|
||||
table := rule.AdditionalTable
|
||||
selections := config.AdditionalResults
|
||||
if bigWin {
|
||||
table = rule.BigWinTable
|
||||
selections = config.BigWinResults
|
||||
}
|
||||
if selected, ok := selectExtractionRule(selections[table], equipmentGrade, random); ok {
|
||||
quantity := int64(math.Floor(float64(equipmentGrade) * selected.QuantityMultiplier))
|
||||
if quantity < 1 {
|
||||
quantity = 1
|
||||
}
|
||||
plan.Materials = mergeEntry(plan.Materials, RecipeEntry{ItemID: selected.ItemID, Count: quantity})
|
||||
}
|
||||
gain := extractor.MinimumExperienceGain
|
||||
if extractor.MaximumExperienceGain > gain {
|
||||
gain += random.Intn(extractor.MaximumExperienceGain - gain + 1)
|
||||
}
|
||||
plan.ExperienceGain = int64(gain)
|
||||
plan.FinalExperience = saturatingAdd(experience, plan.ExperienceGain)
|
||||
plan.LevelChanged = config.Level(experience) != config.Level(plan.FinalExperience)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadConfig(jobType byte, pvfPath, jobName, extractionSection string) (*Config, error) {
|
||||
doc, err := readDocument(c.source, pvfPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &Config{JobType: jobType, AutoLearnRecipes: map[int]int64{}, Skills: map[int]int{}, Recipes: map[int64]Recipe{}, Extractors: map[int64]Extractor{}, ExtractionRules: map[extractionRuleKey]ExtractionRule{}, AdditionalResults: map[int][]SelectionRule{}, BigWinResults: map[int][]SelectionRule{}}
|
||||
thresholds, found := doc.Section("expertness exp")
|
||||
if !found || len(thresholds) == 0 || len(thresholds)%3 != 0 {
|
||||
return nil, fmt.Errorf("%s [expertness exp] row width tokens=%d", pvfPath, len(thresholds))
|
||||
}
|
||||
for i := 0; i < len(thresholds); i += 3 {
|
||||
if thresholds[i].Kind != dnfpvf.TokenInt {
|
||||
return nil, fmt.Errorf("%s [expertness exp] threshold row=%d", pvfPath, i/3)
|
||||
}
|
||||
config.ExperienceThresholds = append(config.ExperienceThresholds, thresholds[i].Int)
|
||||
}
|
||||
for _, cost := range doc.Ints("giveup cost") {
|
||||
if cost < 0 {
|
||||
return nil, fmt.Errorf("%s [giveup cost] contains negative value %d", pvfPath, cost)
|
||||
}
|
||||
config.GiveUpCosts = append(config.GiveUpCosts, cost)
|
||||
}
|
||||
if len(config.GiveUpCosts) == 0 {
|
||||
return nil, fmt.Errorf("%s [giveup cost] is empty", pvfPath)
|
||||
}
|
||||
if err := parsePairs(doc.Ints("auto learn recipe"), func(k, v int64) { config.AutoLearnRecipes[int(k)] = v }); err != nil {
|
||||
return nil, fmt.Errorf("%s [auto learn recipe]: %w", pvfPath, err)
|
||||
}
|
||||
if err := parsePairs(doc.Ints("skill"), func(k, v int64) { config.Skills[int(k)] = int(v) }); err != nil {
|
||||
return nil, fmt.Errorf("%s [skill]: %w", pvfPath, err)
|
||||
}
|
||||
rates := doc.Ints("compound rate")
|
||||
if len(rates)%4 != 0 {
|
||||
return nil, fmt.Errorf("%s [compound rate] row width", pvfPath)
|
||||
}
|
||||
for i := 0; i < len(rates); i += 4 {
|
||||
config.CompoundRates = append(config.CompoundRates, CompoundRate{int(rates[i]), int(rates[i+1]), int(rates[i+2]), int(rates[i+3])})
|
||||
}
|
||||
extractExp, err := parseRanges(doc.Ints("extract exp"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s [extract exp]: %w", pvfPath, err)
|
||||
}
|
||||
productExp := map[int64][2]int{}
|
||||
if values := doc.Ints("product exp"); len(values) > 0 {
|
||||
productExp, err = parseRanges(values)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s [product exp]: %w", pvfPath, err)
|
||||
}
|
||||
}
|
||||
items := doc.Ints("items")
|
||||
if len(items) == 0 || len(items)%4 != 0 {
|
||||
return nil, fmt.Errorf("%s [items] row width", pvfPath)
|
||||
}
|
||||
for i := 0; i < len(items); i += 4 {
|
||||
recipeID, productID, required := items[i+1], items[i+2], int(items[i+3])
|
||||
experienceRange := productExp[recipeID]
|
||||
if recipe, recipeErr := c.loadRecipe(recipeID, productID, required, experienceRange, config.Skills); recipeErr == nil {
|
||||
config.Recipes[recipeID] = recipe
|
||||
}
|
||||
if experienceRange, ok := extractExp[productID]; ok {
|
||||
if extractorErr := c.validateExtractor(productID, required, jobName, extractionSection); extractorErr != nil {
|
||||
return nil, extractorErr
|
||||
}
|
||||
config.Extractors[productID] = Extractor{productID, required, experienceRange[0], experienceRange[1]}
|
||||
}
|
||||
}
|
||||
if len(config.Recipes) == 0 || len(config.Extractors) == 0 {
|
||||
return nil, fmt.Errorf("%s has no recipes or extractors", pvfPath)
|
||||
}
|
||||
values := sectionNumbers(doc, "extraction result")
|
||||
if len(values) == 0 || len(values)%8 != 0 {
|
||||
return nil, fmt.Errorf("%s [extraction result] row width", pvfPath)
|
||||
}
|
||||
for i := 0; i < len(values); i += 8 {
|
||||
key := extractionRuleKey{int64(values[i]), int(values[i+1]), int(values[i+2])}
|
||||
config.ExtractionRules[key] = ExtractionRule{int64(values[i+3]), values[i+4], int(values[i+5]), int(values[i+6]), int(values[i+7])}
|
||||
}
|
||||
if err := parseSelections(sectionNumbers(doc, "additional result"), config.AdditionalResults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parseSelections(sectionNumbers(doc, "big win result"), config.BigWinResults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if jobType == EnchanterType {
|
||||
base := doc.Ints("enchanter extraction result item")
|
||||
if len(base) != 2 || base[0] <= 0 || base[1] <= 0 {
|
||||
return nil, fmt.Errorf("%s [enchanter extraction result item] is invalid", pvfPath)
|
||||
}
|
||||
config.ExtractionBaseConst = float64(base[1])
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadRecipe(recipeID, productID int64, required int, experienceRange [2]int, skills map[int]int) (Recipe, error) {
|
||||
ref, ok := c.items[recipeID]
|
||||
if !ok || ref.kind != itemStackable {
|
||||
return Recipe{}, ErrRecipeUnavailable
|
||||
}
|
||||
doc, err := c.itemDocument(ref)
|
||||
if err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
category := normalizeTag(strings.Join(doc.Texts("item category"), " "))
|
||||
if !strings.Contains(normalizeTag(firstText(doc, "stackable type")), "recipe") || category != "expertjob recipe" {
|
||||
return Recipe{}, ErrRecipeUnavailable
|
||||
}
|
||||
needSkill := doc.Ints("need skill")
|
||||
if len(needSkill) < 2 || needSkill[1] <= 0 {
|
||||
return Recipe{}, ErrRecipeUnavailable
|
||||
}
|
||||
if _, ok := skills[int(needSkill[0])]; !ok {
|
||||
return Recipe{}, ErrRecipeUnavailable
|
||||
}
|
||||
materials, outputs, gold := parseRecipeEntries(doc)
|
||||
if len(materials) == 0 || len(outputs) != 1 || outputs[0].ItemID != productID {
|
||||
return Recipe{}, ErrRecipeUnavailable
|
||||
}
|
||||
return Recipe{
|
||||
RecipeItemID: recipeID, ProductItemID: productID, RequiredLevel: required,
|
||||
MinimumExperienceGain: experienceRange[0], MaximumExperienceGain: experienceRange[1],
|
||||
Materials: materials, Output: outputs[0], GoldCost: gold,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) validateExtractor(itemID int64, required int, jobName, extractionSection string) error {
|
||||
ref, ok := c.items[itemID]
|
||||
if !ok || ref.kind != itemStackable {
|
||||
return ErrExtractorInvalid
|
||||
}
|
||||
doc, err := c.itemDocument(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobOnly := normalizeTag(firstText(doc, "expertjob only"))
|
||||
level, levelOK := doc.Int("expertjob only")
|
||||
extraction, extractionOK := doc.Int(extractionSection)
|
||||
if jobOnly != normalizeTag(jobName) || !levelOK || int(level) != required || !extractionOK || extraction < 0 {
|
||||
return fmt.Errorf("%w: item=%d job=%q level=%d level_found=%t required=%d extraction=%d extraction_found=%t section=%q", ErrExtractorInvalid, itemID, jobOnly, level, levelOK, required, extraction, extractionOK, extractionSection)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) itemDocument(ref itemReference) (*dnfpvf.Document, error) {
|
||||
root := "stackable"
|
||||
if ref.kind == itemEquipment {
|
||||
root = "equipment"
|
||||
}
|
||||
clean := cleanPath(ref.path)
|
||||
candidates := []string{clean}
|
||||
if !strings.HasPrefix(strings.ToLower(clean), root+"/") {
|
||||
candidates = append(candidates, path.Join(root, clean))
|
||||
}
|
||||
for _, candidate := range append([]string(nil), candidates...) {
|
||||
dir, base := path.Split(candidate)
|
||||
if base != "" && !strings.HasPrefix(strings.ToLower(base), "(r)") {
|
||||
candidates = append(candidates, dir+"(r)"+base)
|
||||
}
|
||||
}
|
||||
var last error
|
||||
for _, candidate := range candidates {
|
||||
doc, err := readDocument(c.source, candidate)
|
||||
if err == nil {
|
||||
return doc, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
func loadItemList(source dnfpvf.Source, pvfPath string, kind itemKind) (map[int64]itemReference, error) {
|
||||
doc, err := readDocument(source, pvfPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]itemReference)
|
||||
for _, entry := range dnfpvf.ParseList(doc) {
|
||||
if entry.ID > 0 {
|
||||
out[entry.ID] = itemReference{kind, cleanPath(entry.Path)}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readDocument(source dnfpvf.Source, pvfPath string) (*dnfpvf.Document, error) {
|
||||
text, err := source.ReadText(pvfPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", pvfPath, err)
|
||||
}
|
||||
doc, err := dnfpvf.Parse(pvfPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", pvfPath, err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadEquipmentSellRate() error {
|
||||
if c == nil || c.source == nil {
|
||||
return ErrCatalogUnavailable
|
||||
}
|
||||
doc, err := readDocument(c.source, "equipment/pricetable.tbl")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rates := doc.Ints("rate")
|
||||
if len(rates) == 0 || rates[0] <= 0 || rates[0] > math.MaxInt32 {
|
||||
return errors.New("equipment/pricetable.tbl [rate] is invalid")
|
||||
}
|
||||
c.equipmentSellRatePermille = rates[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanPath(value string) string {
|
||||
return strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")), "./"), "/")
|
||||
}
|
||||
func normalizeTag(value string) string {
|
||||
return strings.ToLower(strings.Trim(strings.TrimSpace(strings.ReplaceAll(value, "`", "")), "[]"))
|
||||
}
|
||||
func firstText(doc *dnfpvf.Document, name string) string { value, _ := doc.Text(name); return value }
|
||||
|
||||
func parsePairs(values []int64, add func(int64, int64)) error {
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return errors.New("row width is not 2")
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
if values[i] <= 0 || values[i+1] <= 0 {
|
||||
return errors.New("non-positive pair")
|
||||
}
|
||||
if _, ok := seen[values[i]]; ok {
|
||||
return errors.New("duplicate key")
|
||||
}
|
||||
seen[values[i]] = struct{}{}
|
||||
add(values[i], values[i+1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRanges(values []int64) (map[int64][2]int, error) {
|
||||
if len(values) == 0 || len(values)%3 != 0 {
|
||||
return nil, errors.New("row width is not 3")
|
||||
}
|
||||
out := map[int64][2]int{}
|
||||
for i := 0; i < len(values); i += 3 {
|
||||
if values[i] <= 0 || values[i+1] < 0 || values[i+2] < values[i+1] {
|
||||
return nil, errors.New("invalid range")
|
||||
}
|
||||
out[values[i]] = [2]int{int(values[i+1]), int(values[i+2])}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sectionNumbers(doc *dnfpvf.Document, name string) []float64 { return doc.Numbers(name) }
|
||||
|
||||
func parseSelections(values []float64, target map[int][]SelectionRule) error {
|
||||
if len(values)%6 != 0 {
|
||||
return errors.New("expert job selection row width is not 6")
|
||||
}
|
||||
for i := 0; i < len(values); i += 6 {
|
||||
table := int(values[i])
|
||||
rule := SelectionRule{int(values[i+1]), int(values[i+2]), int64(values[i+3]), int(values[i+4]), values[i+5]}
|
||||
if table <= 0 || rule.MinimumLevel < 0 || rule.MaximumLevel < rule.MinimumLevel || rule.ItemID <= 0 || rule.Weight <= 0 || rule.QuantityMultiplier <= 0 {
|
||||
return errors.New("invalid expert job selection")
|
||||
}
|
||||
target[table] = append(target[table], rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRecipeEntries(doc *dnfpvf.Document) ([]RecipeEntry, []RecipeEntry, int64) {
|
||||
values := doc.Ints("int data")
|
||||
if len(values) > 0 {
|
||||
position, materialCount := 1, int(values[0])
|
||||
if materialCount >= 0 && len(values) >= position+materialCount*2 {
|
||||
materials := make([]RecipeEntry, 0, materialCount)
|
||||
for i := 0; i < materialCount; i++ {
|
||||
materials = append(materials, RecipeEntry{values[position], values[position+1]})
|
||||
position += 2
|
||||
}
|
||||
if position < len(values) {
|
||||
outputCount := int(values[position])
|
||||
position++
|
||||
if outputCount >= 0 && len(values) >= position+outputCount*2 {
|
||||
outputs := make([]RecipeEntry, 0, outputCount)
|
||||
for i := 0; i < outputCount; i++ {
|
||||
outputs = append(outputs, RecipeEntry{values[position], values[position+1]})
|
||||
position += 2
|
||||
}
|
||||
return materials, outputs, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
input := doc.Ints("input item")
|
||||
output := doc.Ints("output item")
|
||||
materials := make([]RecipeEntry, 0, len(input)/2)
|
||||
var gold int64
|
||||
for i := 0; i+1 < len(input); i += 2 {
|
||||
if input[i] == 0 {
|
||||
gold = max(gold, input[i+1])
|
||||
} else if input[i] > 0 && input[i+1] > 0 {
|
||||
materials = append(materials, RecipeEntry{input[i], input[i+1]})
|
||||
}
|
||||
}
|
||||
outputs := make([]RecipeEntry, 0, len(output)/2)
|
||||
for i := 0; i+1 < len(output); i += 2 {
|
||||
if output[i] > 0 && output[i+1] > 0 {
|
||||
outputs = append(outputs, RecipeEntry{output[i], output[i+1]})
|
||||
}
|
||||
}
|
||||
return materials, outputs, gold
|
||||
}
|
||||
|
||||
func multiplyEntries(values []RecipeEntry, multiplier int64) []RecipeEntry {
|
||||
out := make([]RecipeEntry, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, RecipeEntry{value.ItemID, value.Count * multiplier})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func mergeEntry(values []RecipeEntry, entry RecipeEntry) []RecipeEntry {
|
||||
for i := range values {
|
||||
if values[i].ItemID == entry.ItemID {
|
||||
values[i].Count += entry.Count
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, entry)
|
||||
}
|
||||
func saturatingAdd(left, right int64) int64 {
|
||||
if right > 0 && left > math.MaxInt64-right {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return left + right
|
||||
}
|
||||
|
||||
func selectExtractionRule(values []SelectionRule, level int, rng randomSource) (SelectionRule, bool) {
|
||||
total := 0
|
||||
eligible := make([]SelectionRule, 0, len(values))
|
||||
for _, value := range values {
|
||||
if level >= value.MinimumLevel && level <= value.MaximumLevel {
|
||||
eligible = append(eligible, value)
|
||||
total += value.Weight
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return SelectionRule{}, false
|
||||
}
|
||||
roll := rng.Intn(total)
|
||||
for _, value := range eligible {
|
||||
if roll < value.Weight {
|
||||
return value, true
|
||||
}
|
||||
roll -= value.Weight
|
||||
}
|
||||
return SelectionRule{}, false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealScriptPVFAllExpertJobsCatalog(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify expert-job catalog")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := Load(context.Background(), archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, jobType := range []byte{EnchanterType, AlchemistType, DollControllerType} {
|
||||
config, ok := catalog.Config(jobType)
|
||||
if !ok || config.Level(0) != 1 || len(config.AutoRecipeIDs(0)) == 0 ||
|
||||
len(config.GiveUpCosts) == 0 || len(config.Recipes) == 0 || len(config.Extractors) == 0 || len(config.ExtractionRules) == 0 {
|
||||
t.Fatalf("expert job type=%d config=%+v found=%t", jobType, config, ok)
|
||||
}
|
||||
if !reflect.DeepEqual(config.GiveUpCosts, []int64{1000, 10_000, 100_000, 1_000_000}) {
|
||||
t.Fatalf("expert job type=%d give-up costs=%v", jobType, config.GiveUpCosts)
|
||||
}
|
||||
t.Logf("type=%d levels=%d recipes=%d extractors=%d extraction_rules=%d auto_level1=%v",
|
||||
jobType, len(config.ExperienceThresholds), len(config.Recipes), len(config.Extractors),
|
||||
len(config.ExtractionRules), config.AutoRecipeIDs(0))
|
||||
}
|
||||
enchanter, ok := catalog.Enchanter()
|
||||
if !ok || enchanter.MaximumStoreCharge != 10_000 || enchanter.InitialEndurance <= 0 ||
|
||||
enchanter.Recipes.ExtractionBaseConst != 500 || len(enchanter.CardRecipes) < 5 || len(enchanter.Cards) == 0 ||
|
||||
enchanter.CardRecipes[10015129].RequiredLevel != 1 || enchanter.Cards[3619].Qualification != 0 || enchanter.BeadByCard[3619] != 2600313 {
|
||||
t.Fatalf("enchanter config=%+v found=%t", enchanter, ok)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
recipeID int64
|
||||
essenceCount int64
|
||||
}{
|
||||
{recipeID: 10015132, essenceCount: 3},
|
||||
{recipeID: 10159564, essenceCount: 4},
|
||||
} {
|
||||
recipe, found := enchanter.CardRecipes[test.recipeID]
|
||||
if !found || expertJobRecipeMaterialCount(recipe.Materials, 2610041) != test.essenceCount {
|
||||
t.Fatalf("enchanter recipe=%d materials=%+v found=%t", test.recipeID, recipe.Materials, found)
|
||||
}
|
||||
}
|
||||
disjointer, ok := catalog.Disjointer()
|
||||
if !ok || disjointer.InitialEndurance != 300 || disjointer.MaximumStoreCharge != 10_000 || len(disjointer.GiveUpCosts) == 0 || disjointer.BaseConst != 150 ||
|
||||
len(disjointer.RepairRules) != 11 || disjointer.UpgradeCosts[2] != 30_000 || disjointer.CharacterLevelLimits[2] != 20 {
|
||||
t.Fatalf("disjointer config=%+v found=%t", disjointer, ok)
|
||||
}
|
||||
if !reflect.DeepEqual(disjointer.GiveUpCosts, []int64{1000, 10_000, 100_000, 1_000_000}) {
|
||||
t.Fatalf("disjointer give-up costs=%v", disjointer.GiveUpCosts)
|
||||
}
|
||||
disjointerMaterials := make(map[int64]bool)
|
||||
for _, rule := range disjointer.Results {
|
||||
disjointerMaterials[rule.ItemID] = true
|
||||
}
|
||||
for _, tables := range []map[int][]DisjointerSelectionRule{disjointer.AdditionalResults, disjointer.BigWinResults} {
|
||||
for _, rules := range tables {
|
||||
for _, rule := range rules {
|
||||
disjointerMaterials[rule.ItemID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
disjointerMaterials[disjointer.SelfServiceItemID] = true
|
||||
for _, required := range []int64{3037, 3166, 3167, 3228, 3242, 3245, 3262, 3299, 3300, 3332, 3336, 3337, 3338, 3339, 2610045, 10099777} {
|
||||
if !disjointerMaterials[required] {
|
||||
t.Fatalf("disjointer runtime tables are missing material=%d: %v", required, disjointerMaterials)
|
||||
}
|
||||
}
|
||||
for _, dollControllerSoul := range []int64{2610042, 2610043, 2610044, 2610048} {
|
||||
if disjointerMaterials[dollControllerSoul] {
|
||||
t.Fatalf("doll-controller soul=%d leaked into disjointer runtime tables", dollControllerSoul)
|
||||
}
|
||||
}
|
||||
t.Logf("enchanter card_recipes=%d cards=%d beads=%d repair_rules=%d; disjointer results=%d repair_rules=%d",
|
||||
len(enchanter.CardRecipes), len(enchanter.Cards), len(enchanter.BeadByCard), len(enchanter.RepairRules),
|
||||
len(disjointer.Results), len(disjointer.RepairRules))
|
||||
}
|
||||
|
||||
func expertJobRecipeMaterialCount(materials []RecipeEntry, itemID int64) int64 {
|
||||
for _, material := range materials {
|
||||
if material.ItemID == itemID {
|
||||
return material.Count
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanCompoundUsesLearnedRecipeRateAndSuccessfulExperienceOnly(t *testing.T) {
|
||||
config := &Config{
|
||||
JobType: AlchemistType,
|
||||
ExperienceThresholds: []int64{10, 20, 30},
|
||||
AutoLearnRecipes: map[int]int64{1: 1001},
|
||||
CompoundRates: []CompoundRate{{
|
||||
MaximumLevelDifference: 3,
|
||||
SuccessRatePercent: 50,
|
||||
MinimumExperienceGain: 2,
|
||||
MaximumExperienceGain: 4,
|
||||
}},
|
||||
Recipes: map[int64]Recipe{
|
||||
1002: {
|
||||
RecipeItemID: 1002,
|
||||
ProductItemID: 2002,
|
||||
RequiredLevel: 2,
|
||||
Materials: []RecipeEntry{{ItemID: 3001, Count: 2}},
|
||||
Output: RecipeEntry{ItemID: 2002, Count: 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
catalog := &Catalog{configs: map[byte]*Config{AlchemistType: config}}
|
||||
rolls := []int{0, 2, 99}
|
||||
position := 0
|
||||
plan, err := catalog.PlanCompoundWithLearned(AlchemistType, 0, 1002, 2, true, func(int) int {
|
||||
value := rolls[position]
|
||||
position++
|
||||
return value
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.SuccessCount != 1 || plan.FailureCount != 1 || plan.ExperienceGain != 4 || plan.FinalExperience != 4 ||
|
||||
len(plan.Materials) != 1 || plan.Materials[0].Count != 4 ||
|
||||
len(plan.AttemptedOutputs) != 1 || plan.AttemptedOutputs[0].Count != 6 ||
|
||||
len(plan.Rewards) != 1 || plan.Rewards[0].Count != 3 {
|
||||
t.Fatalf("compound plan = %+v", plan)
|
||||
}
|
||||
if _, err := catalog.PlanCompound(AlchemistType, 0, 1002, 1, nil); err != ErrRecipeUnavailable {
|
||||
t.Fatalf("unlearned recipe error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanExtractionUsesEquipmentStateAndWeightedAdditionalResult(t *testing.T) {
|
||||
config := &Config{
|
||||
JobType: DollControllerType,
|
||||
ExperienceThresholds: []int64{10, 20},
|
||||
Extractors: map[int64]Extractor{
|
||||
4001: {ItemID: 4001, RequiredLevel: 1, MinimumExperienceGain: 1, MaximumExperienceGain: 1},
|
||||
},
|
||||
ExtractionRules: map[extractionRuleKey]ExtractionRule{
|
||||
{extractorID: 4001, rarity: 3, state: 1}: {ResultItemID: 5001, Multiplier: 0.1, AdditionalTable: 2},
|
||||
},
|
||||
AdditionalResults: map[int][]SelectionRule{
|
||||
2: {{MinimumLevel: 1, MaximumLevel: 99, ItemID: 5002, Weight: 1, QuantityMultiplier: 0.2}},
|
||||
},
|
||||
}
|
||||
catalog := &Catalog{configs: map[byte]*Config{DollControllerType: config}}
|
||||
plan, err := catalog.PlanExtraction(DollControllerType, 0, 4001, EquipmentMetadata{Rarity: 3, Level: 50, State: 1}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Materials) != 2 || plan.Materials[0] != (RecipeEntry{ItemID: 5001, Count: 5}) ||
|
||||
plan.Materials[1] != (RecipeEntry{ItemID: 5002, Count: 10}) || plan.ExperienceGain != 1 {
|
||||
t.Fatalf("extraction plan = %+v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEnchanterBeadAndStoreUseCardQualificationAndEndurance(t *testing.T) {
|
||||
recipes := &Config{JobType: EnchanterType, ExperienceThresholds: []int64{10, 20}, Recipes: map[int64]Recipe{}}
|
||||
enchanter := &EnchanterConfig{
|
||||
Recipes: recipes, InitialEndurance: 300, EnduranceReduction: 3, EnduranceMinimumLevel: 1,
|
||||
CardRecipes: map[int64]EnchanterCardRecipe{100: {Qualification: 1, RecipeItemID: 100, RequiredLevel: 1, Materials: []RecipeEntry{{ItemID: 300, Count: 2}}}},
|
||||
Cards: map[int64]EnchanterCard{200: {Qualification: 1}}, BeadByCard: map[int64]int64{200: 400},
|
||||
CardExperienceByLevel: map[int]EnchanterCardExperienceRule{1: {Level: 1, SuccessRates: [5]int{100, 70, 60, 50, 0}, MinimumExperienceGain: 3, MaximumExperienceGain: 6}},
|
||||
}
|
||||
catalog := &Catalog{configs: map[byte]*Config{EnchanterType: recipes}, enchanter: enchanter}
|
||||
bead, err := catalog.PlanEnchanterBead(0, 100, 200, func(limit int) int {
|
||||
if limit == 100 {
|
||||
return 69
|
||||
}
|
||||
return limit - 1
|
||||
})
|
||||
if err != nil || !bead.Success || bead.BeadItemID != 400 || bead.ExperienceGain != 6 || bead.FinalExperience != 6 {
|
||||
t.Fatalf("bead plan=%+v error=%v", bead, err)
|
||||
}
|
||||
store, err := catalog.PlanEnchanterStore(0, 100, 200, 3, func(int) int { return 99 })
|
||||
if err != nil || store.Success || store.EnduranceReduction != 3 || store.ExperienceGain != 0 || store.FinalExperience != 0 {
|
||||
t.Fatalf("store plan=%+v error=%v", store, err)
|
||||
}
|
||||
if _, err := catalog.PlanEnchanterStore(0, 100, 200, 2, nil); err != ErrMachineEndurance {
|
||||
t.Fatalf("low-endurance error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEnchanterHighPresentationQualificationUsesFinalAuthoredRate(t *testing.T) {
|
||||
recipes := &Config{JobType: EnchanterType, ExperienceThresholds: []int64{10}, Recipes: map[int64]Recipe{}}
|
||||
enchanter := &EnchanterConfig{
|
||||
Recipes: recipes, InitialEndurance: 300, EnduranceReduction: 3, EnduranceMinimumLevel: 1,
|
||||
CardRecipes: map[int64]EnchanterCardRecipe{600: {Qualification: 6, RecipeItemID: 600, RequiredLevel: 1}},
|
||||
Cards: map[int64]EnchanterCard{700: {Qualification: 6}}, BeadByCard: map[int64]int64{700: 800},
|
||||
CardExperienceByLevel: map[int]EnchanterCardExperienceRule{1: {
|
||||
Level: 1, SuccessRates: [5]int{10, 20, 30, 40, 100}, MinimumExperienceGain: 1, MaximumExperienceGain: 1,
|
||||
}},
|
||||
}
|
||||
catalog := &Catalog{configs: map[byte]*Config{EnchanterType: recipes}, enchanter: enchanter}
|
||||
bead, err := catalog.PlanEnchanterBead(0, 600, 700, func(int) int { return 99 })
|
||||
if err != nil || !bead.Success || bead.BeadItemID != 800 {
|
||||
t.Fatalf("bead=%+v err=%v", bead, err)
|
||||
}
|
||||
store, err := catalog.PlanEnchanterStore(0, 600, 700, 300, func(int) int { return 99 })
|
||||
if err != nil || !store.Success {
|
||||
t.Fatalf("store=%+v err=%v", store, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDisjointerRepairAndUpgradeUseMachineRules(t *testing.T) {
|
||||
config := &DisjointerConfig{
|
||||
InitialEndurance: 300, BaseConst: 150, EnduranceReduceMin: 1, EnduranceReduceMax: 1,
|
||||
ExperienceGainMin: 1, ExperienceGainMax: 1,
|
||||
ExperienceThresholds: []int64{10, 20},
|
||||
Results: map[disjointerResultKey]DisjointerResultRule{{MachineGrade: 0, Rarity: 2, State: 0}: {ItemID: 500, Multiplier: 1}},
|
||||
RepairRules: []RepairRule{{FullRepairCost: 300, MaxEndurance: 300}, {FullRepairCost: 600, MaxEndurance: 400}},
|
||||
UpgradeCosts: map[int]int64{2: 1000}, CharacterLevelLimits: map[int]int{2: 20},
|
||||
}
|
||||
catalog := &Catalog{disjointer: config}
|
||||
plan, err := catalog.PlanDisjointer(0, 1, EquipmentMetadata{Rarity: 2, Grade: 20, SellGold: 150}, false, nil)
|
||||
if err != nil || len(plan.Materials) != 1 || plan.Materials[0] != (RecipeEntry{ItemID: 500, Count: 1}) || plan.EnduranceReduction != 1 || plan.ExperienceGain != 1 {
|
||||
t.Fatalf("disjointer plan=%+v error=%v", plan, err)
|
||||
}
|
||||
repair, err := PlanMachineRepair(50, 200, config.RepairRules[0])
|
||||
if err != nil || repair.Cost != 50 || repair.Endurance != 250 || repair.Gold != 0 {
|
||||
t.Fatalf("repair plan=%+v error=%v", repair, err)
|
||||
}
|
||||
upgrade, err := config.PlanUpgrade(1500, 10, 300, 1, 20)
|
||||
if err != nil || upgrade.Grade != 2 || upgrade.Gold != 500 || upgrade.Endurance != 400 {
|
||||
t.Fatalf("upgrade plan=%+v error=%v", upgrade, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanGiveUpUsesPVFCostTierAndCapsAtLastTier(t *testing.T) {
|
||||
costs := []int64{1000, 10_000, 100_000, 1_000_000}
|
||||
first, err := PlanGiveUp(60_000, 0, costs)
|
||||
if err != nil || first.FinalGold != 59_000 || first.Cost != 1000 || first.FinalState != 1 {
|
||||
t.Fatalf("first give-up plan=%+v error=%v", first, err)
|
||||
}
|
||||
last, err := PlanGiveUp(2_000_000, 99, costs)
|
||||
if err != nil || last.FinalGold != 1_000_000 || last.Cost != 1_000_000 || last.FinalState != 3 {
|
||||
t.Fatalf("last give-up plan=%+v error=%v", last, err)
|
||||
}
|
||||
if _, err := PlanGiveUp(999, 0, costs); !errors.Is(err, ErrInsufficientGold) {
|
||||
t.Fatalf("insufficient-gold error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type disjointerResultKey struct {
|
||||
MachineGrade int
|
||||
Rarity int
|
||||
State int
|
||||
}
|
||||
|
||||
type DisjointerResultRule struct {
|
||||
ItemID int64
|
||||
Multiplier float64
|
||||
AdditionalTable int
|
||||
BigWinTable int
|
||||
BigWinChancePercent int
|
||||
}
|
||||
|
||||
type DisjointerSelectionRule struct {
|
||||
MinimumGrade int
|
||||
MaximumGrade int
|
||||
ItemID int64
|
||||
Weight int
|
||||
CountDivisor float64
|
||||
}
|
||||
|
||||
type DisjointerConfig struct {
|
||||
InitialEndurance int64
|
||||
MaximumStoreCharge int64
|
||||
GiveUpCosts []int64
|
||||
BaseConst float64
|
||||
EnduranceReduceMin int
|
||||
EnduranceReduceMax int
|
||||
ExperienceGainMin int
|
||||
ExperienceGainMax int
|
||||
SelfServiceChance int
|
||||
SelfServiceItemID int64
|
||||
SelfServiceItemCount int64
|
||||
Results map[disjointerResultKey]DisjointerResultRule
|
||||
AdditionalResults map[int][]DisjointerSelectionRule
|
||||
BigWinResults map[int][]DisjointerSelectionRule
|
||||
RepairRules []RepairRule
|
||||
ExperienceThresholds []int64
|
||||
UpgradeCosts map[int]int64
|
||||
CharacterLevelLimits map[int]int
|
||||
// ChronicleSetItemID / ChronicleSetCount are [chronicle set disjoint
|
||||
// result]: the fixed refund of 异界 (chronicle, rarity 5) equipment
|
||||
// (异次元碎片 ×20 in the live PVF). The bridge grants it when
|
||||
// reinforcement or amplification destroys the piece. Zero when the file
|
||||
// has no such section.
|
||||
ChronicleSetItemID int64
|
||||
ChronicleSetCount int64
|
||||
}
|
||||
|
||||
// DisjointerChronicleRarity is the PVF rarity index of 异界 equipment.
|
||||
const DisjointerChronicleRarity = 5
|
||||
|
||||
// ChronicleSetResult returns the fixed [chronicle set disjoint result] refund.
|
||||
func (c *DisjointerConfig) ChronicleSetResult() (itemID, count int64, ok bool) {
|
||||
if c == nil || c.ChronicleSetItemID <= 0 || c.ChronicleSetCount <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return c.ChronicleSetItemID, c.ChronicleSetCount, true
|
||||
}
|
||||
|
||||
// The disjointer.exj [disjoint result] state column: 0 ordinary equipment, 1
|
||||
// equipment carrying an otherverse aura (红字), 2 boss-unique equipment. The
|
||||
// otherverse aura rolls [additional result] table 6 (浓缩的异界精髓 /
|
||||
// 矛盾的结晶体 in the live PVF).
|
||||
const (
|
||||
DisjointerOtherverseState = 1
|
||||
DisjointerOtherverseTable = 6
|
||||
)
|
||||
|
||||
// ResultRule returns the [disjoint result] row for one machine-grade column
|
||||
// (the file's first column, 0 for the lowest machine), equipment rarity and
|
||||
// state.
|
||||
func (c *DisjointerConfig) ResultRule(machineGradeColumn, rarity, state int) (DisjointerResultRule, bool) {
|
||||
if c == nil {
|
||||
return DisjointerResultRule{}, false
|
||||
}
|
||||
rule, found := c.Results[disjointerResultKey{machineGradeColumn, rarity, state}]
|
||||
return rule, found
|
||||
}
|
||||
|
||||
// SelectAdditional picks one weighted [additional result] row of a table
|
||||
// whose level range covers level.
|
||||
func (c *DisjointerConfig) SelectAdditional(table, level int, rng RandomFunc) (DisjointerSelectionRule, bool) {
|
||||
if c == nil {
|
||||
return DisjointerSelectionRule{}, false
|
||||
}
|
||||
return selectDisjointerRule(c.AdditionalResults[table], level, randomSource(rng))
|
||||
}
|
||||
|
||||
// SelectBigWin picks one weighted [big win result] row of a table whose level
|
||||
// range covers level.
|
||||
func (c *DisjointerConfig) SelectBigWin(table, level int, rng RandomFunc) (DisjointerSelectionRule, bool) {
|
||||
if c == nil {
|
||||
return DisjointerSelectionRule{}, false
|
||||
}
|
||||
return selectDisjointerRule(c.BigWinResults[table], level, randomSource(rng))
|
||||
}
|
||||
|
||||
// LevelDivisorCount mirrors 86JP PlayerDisjointResultCalculator.
|
||||
// RollLevelDivisorCount: floor(level / divisor) plus one more on a fractional
|
||||
// roll, never below zero. A divisor of 100 therefore yields one item on
|
||||
// level% of the rolls and nothing otherwise.
|
||||
func LevelDivisorCount(level int, divisor float64, rng RandomFunc) int64 {
|
||||
return levelDivisorCount(level, divisor, randomSource(rng))
|
||||
}
|
||||
|
||||
func levelDivisorCount(level int, divisor float64, random randomSource) int64 {
|
||||
if level <= 0 || divisor <= 0 || random == nil {
|
||||
return 0
|
||||
}
|
||||
value := float64(level) / divisor
|
||||
count := math.Floor(value)
|
||||
if fraction := value - count; fraction > 0 && float64(random.Intn(10000)) < fraction*10000 {
|
||||
count++
|
||||
}
|
||||
if count < 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(count)
|
||||
}
|
||||
|
||||
func (c *DisjointerConfig) Level(experience int64) int {
|
||||
if c == nil || len(c.ExperienceThresholds) == 0 {
|
||||
return 0
|
||||
}
|
||||
level := 1
|
||||
for _, threshold := range c.ExperienceThresholds {
|
||||
if experience < threshold {
|
||||
break
|
||||
}
|
||||
level++
|
||||
}
|
||||
return min(level, len(c.ExperienceThresholds))
|
||||
}
|
||||
|
||||
func (c *DisjointerConfig) RepairRule(grade int) (RepairRule, bool) {
|
||||
if c == nil || grade <= 0 || grade > len(c.RepairRules) {
|
||||
return RepairRule{}, false
|
||||
}
|
||||
return c.RepairRules[grade-1], true
|
||||
}
|
||||
|
||||
type DisjointerPlan struct {
|
||||
Materials []RecipeEntry
|
||||
EnduranceReduction int64
|
||||
ExperienceGain int64
|
||||
FinalExperience int64
|
||||
LevelChanged bool
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanDisjointer(experience int64, machineGrade int, equipment EquipmentMetadata, selfService bool, rng RandomFunc) (DisjointerPlan, error) {
|
||||
config, ok := c.Disjointer()
|
||||
if !ok || machineGrade <= 0 {
|
||||
return DisjointerPlan{}, ErrMachineInvalid
|
||||
}
|
||||
rule, found := config.Results[disjointerResultKey{machineGrade - 1, equipment.Rarity, equipment.State}]
|
||||
if !found && equipment.State == 1 {
|
||||
return DisjointerPlan{}, ErrMachineGradeTooLow
|
||||
}
|
||||
if !found || rule.ItemID <= 0 || rule.Multiplier <= 0 || config.BaseConst <= 0 {
|
||||
return DisjointerPlan{}, ErrExtractionInvalid
|
||||
}
|
||||
random := randomSource(RandomFunc(rng))
|
||||
equipmentGrade := equipment.Grade
|
||||
if equipmentGrade <= 0 {
|
||||
equipmentGrade = equipment.Level
|
||||
}
|
||||
adjustedSellGold := math.Max(1, math.Floor(float64(max(int64(1), equipment.SellGold))*1.1))
|
||||
base := int64(math.Max(1, math.Floor(adjustedSellGold*rule.Multiplier/config.BaseConst)))
|
||||
plan := DisjointerPlan{Materials: []RecipeEntry{{rule.ItemID, base}}}
|
||||
bigWin := random.Intn(10000) < max(0, min(100, rule.BigWinChancePercent))*100
|
||||
table := rule.AdditionalTable
|
||||
selections := config.AdditionalResults
|
||||
if bigWin {
|
||||
table = rule.BigWinTable
|
||||
selections = config.BigWinResults
|
||||
}
|
||||
if selected, selectedOK := selectDisjointerRule(selections[table], equipmentGrade, random); selectedOK {
|
||||
quantity := int64(1)
|
||||
if selected.CountDivisor > 0 {
|
||||
quantity = int64(math.Floor(float64(equipmentGrade) / selected.CountDivisor))
|
||||
if quantity < 1 {
|
||||
quantity = 1
|
||||
}
|
||||
}
|
||||
plan.Materials = mergeEntry(plan.Materials, RecipeEntry{selected.ItemID, quantity})
|
||||
}
|
||||
// Equipment carrying an otherverse aura (红字) additionally rolls the
|
||||
// [additional result] otherverse table, unless that table was already the
|
||||
// ordinary (non big-win) selection (86JP
|
||||
// PlayerDisjointResultCalculator.Calculate).
|
||||
if equipment.State == DisjointerOtherverseState && (bigWin || table != DisjointerOtherverseTable) {
|
||||
if selected, selectedOK := selectDisjointerRule(config.AdditionalResults[DisjointerOtherverseTable], equipmentGrade, random); selectedOK {
|
||||
if quantity := levelDivisorCount(equipmentGrade, selected.CountDivisor, random); quantity > 0 {
|
||||
plan.Materials = mergeEntry(plan.Materials, RecipeEntry{selected.ItemID, quantity})
|
||||
}
|
||||
}
|
||||
}
|
||||
if selfService && equipment.Rarity > 1 && config.SelfServiceItemID > 0 && config.SelfServiceItemCount > 0 && random.Intn(100) < config.SelfServiceChance {
|
||||
plan.Materials = mergeEntry(plan.Materials, RecipeEntry{config.SelfServiceItemID, config.SelfServiceItemCount})
|
||||
}
|
||||
plan.EnduranceReduction = int64(config.EnduranceReduceMin)
|
||||
if config.EnduranceReduceMax > config.EnduranceReduceMin {
|
||||
plan.EnduranceReduction += int64(random.Intn(config.EnduranceReduceMax - config.EnduranceReduceMin + 1))
|
||||
}
|
||||
plan.ExperienceGain = int64(config.ExperienceGainMin)
|
||||
if config.ExperienceGainMax > config.ExperienceGainMin {
|
||||
plan.ExperienceGain += int64(random.Intn(config.ExperienceGainMax - config.ExperienceGainMin + 1))
|
||||
}
|
||||
plan.FinalExperience = saturatingAdd(experience, plan.ExperienceGain)
|
||||
plan.LevelChanged = config.Level(experience) != config.Level(plan.FinalExperience)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type MachineRepairPlan struct {
|
||||
Gold int64
|
||||
Endurance int64
|
||||
Cost int64
|
||||
}
|
||||
|
||||
func PlanMachineRepair(gold, currentEndurance int64, rule RepairRule) (MachineRepairPlan, error) {
|
||||
if gold < 0 || currentEndurance < 0 || rule.MaxEndurance <= 0 || rule.FullRepairCost <= 0 {
|
||||
return MachineRepairPlan{}, ErrMachineInvalid
|
||||
}
|
||||
current := min(currentEndurance, rule.MaxEndurance)
|
||||
cost := rule.FullRepairCost - current*rule.FullRepairCost/rule.MaxEndurance
|
||||
if cost <= 0 {
|
||||
return MachineRepairPlan{}, ErrMachineInvalid
|
||||
}
|
||||
if gold < cost {
|
||||
unit := rule.FullRepairCost / rule.MaxEndurance
|
||||
if unit <= 0 {
|
||||
return MachineRepairPlan{}, ErrMachineInvalid
|
||||
}
|
||||
cost = gold - gold%unit
|
||||
repaired := cost / unit
|
||||
if cost <= 0 || repaired <= 0 {
|
||||
return MachineRepairPlan{}, ErrMachineInvalid
|
||||
}
|
||||
current = min(rule.MaxEndurance, current+repaired)
|
||||
} else {
|
||||
current = rule.MaxEndurance
|
||||
}
|
||||
return MachineRepairPlan{Gold: gold - cost, Endurance: current, Cost: cost}, nil
|
||||
}
|
||||
|
||||
type DisjointerUpgradePlan struct {
|
||||
Gold int64
|
||||
Grade int
|
||||
Endurance int64
|
||||
Cost int64
|
||||
}
|
||||
|
||||
func (c *DisjointerConfig) PlanUpgrade(gold, experience, endurance int64, machineGrade, characterLevel int) (DisjointerUpgradePlan, error) {
|
||||
if c == nil || gold < 0 || machineGrade <= 0 || characterLevel <= 0 {
|
||||
return DisjointerUpgradePlan{}, ErrMachineInvalid
|
||||
}
|
||||
current, currentOK := c.RepairRule(machineGrade)
|
||||
targetGrade := machineGrade + 1
|
||||
target, targetOK := c.RepairRule(targetGrade)
|
||||
cost, costOK := c.UpgradeCosts[targetGrade]
|
||||
minimumCharacterLevel, levelOK := c.CharacterLevelLimits[targetGrade]
|
||||
if !currentOK || !targetOK || !costOK || cost <= 0 || !levelOK || endurance != current.MaxEndurance || c.Level(experience) < targetGrade {
|
||||
return DisjointerUpgradePlan{}, ErrMachineInvalid
|
||||
}
|
||||
if characterLevel < minimumCharacterLevel {
|
||||
return DisjointerUpgradePlan{}, ErrLevelTooLow
|
||||
}
|
||||
if gold < cost {
|
||||
return DisjointerUpgradePlan{}, ErrInsufficientGold
|
||||
}
|
||||
return DisjointerUpgradePlan{Gold: gold - cost, Grade: targetGrade, Endurance: target.MaxEndurance, Cost: cost}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) loadDisjointerConfig() (*DisjointerConfig, error) {
|
||||
doc, err := readDocument(c.source, DisjointerPVFPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &DisjointerConfig{
|
||||
Results: map[disjointerResultKey]DisjointerResultRule{}, AdditionalResults: map[int][]DisjointerSelectionRule{},
|
||||
BigWinResults: map[int][]DisjointerSelectionRule{}, UpgradeCosts: map[int]int64{}, CharacterLevelLimits: map[int]int{},
|
||||
}
|
||||
config.InitialEndurance, _ = doc.Int("endurance initial value")
|
||||
config.MaximumStoreCharge, _ = doc.Int("limit store charge")
|
||||
for _, cost := range doc.Ints("giveup cost") {
|
||||
if cost < 0 {
|
||||
return nil, errors.New("disjointer [giveup cost] contains a negative value")
|
||||
}
|
||||
config.GiveUpCosts = append(config.GiveUpCosts, cost)
|
||||
}
|
||||
if value, ok := doc.Int("base const"); ok {
|
||||
config.BaseConst = float64(value)
|
||||
}
|
||||
reduce := doc.Ints("endurance reduce")
|
||||
gain := doc.Ints("gain exp")
|
||||
self := doc.Ints("disjoint self service")
|
||||
if len(reduce) != 2 || len(gain) != 2 || len(self) < 3 {
|
||||
return nil, errors.New("disjointer scalar rules are invalid")
|
||||
}
|
||||
config.EnduranceReduceMin, config.EnduranceReduceMax = int(reduce[0]), int(reduce[1])
|
||||
config.ExperienceGainMin, config.ExperienceGainMax = int(gain[0]), int(gain[1])
|
||||
config.SelfServiceChance, config.SelfServiceItemID, config.SelfServiceItemCount = int(self[0]), self[1], self[2]
|
||||
results := doc.Numbers("disjoint result")
|
||||
if len(results) == 0 || len(results)%8 != 0 {
|
||||
return nil, errors.New("disjointer [disjoint result] is invalid")
|
||||
}
|
||||
for i := 0; i < len(results); i += 8 {
|
||||
key := disjointerResultKey{int(results[i]), int(results[i+1]), int(results[i+2])}
|
||||
rule := DisjointerResultRule{int64(results[i+3]), results[i+4], int(results[i+5]), int(results[i+6]), int(results[i+7])}
|
||||
if key.MachineGrade < 0 || key.Rarity < 0 || key.State < 0 || rule.ItemID <= 0 || rule.Multiplier <= 0 {
|
||||
return nil, errors.New("disjointer result rule is invalid")
|
||||
}
|
||||
if _, duplicate := config.Results[key]; duplicate {
|
||||
return nil, errors.New("disjointer result rule is duplicated")
|
||||
}
|
||||
config.Results[key] = rule
|
||||
}
|
||||
if err := parseDisjointerSelections(doc.Numbers("additional result"), config.AdditionalResults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parseDisjointerSelections(doc.Numbers("big win result"), config.BigWinResults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chronicleSet := doc.Ints("chronicle set disjoint result"); len(chronicleSet) != 0 {
|
||||
if len(chronicleSet) != 2 || chronicleSet[0] <= 0 || chronicleSet[1] <= 0 {
|
||||
return nil, errors.New("disjointer [chronicle set disjoint result] is invalid")
|
||||
}
|
||||
config.ChronicleSetItemID, config.ChronicleSetCount = chronicleSet[0], chronicleSet[1]
|
||||
}
|
||||
repairs := doc.Ints("endurance repair cost")
|
||||
if len(repairs) == 0 || len(repairs)%2 != 0 {
|
||||
return nil, errors.New("disjointer repair rules are invalid")
|
||||
}
|
||||
for i := 0; i < len(repairs); i += 2 {
|
||||
if repairs[i] <= 0 || repairs[i+1] <= 0 {
|
||||
return nil, errors.New("disjointer repair rule is invalid")
|
||||
}
|
||||
config.RepairRules = append(config.RepairRules, RepairRule{repairs[i], repairs[i+1]})
|
||||
}
|
||||
thresholdTokens, found := doc.Section("expertness exp")
|
||||
if !found || len(thresholdTokens) == 0 || len(thresholdTokens)%3 != 0 {
|
||||
return nil, errors.New("disjointer experience thresholds are invalid")
|
||||
}
|
||||
for i := 0; i < len(thresholdTokens); i += 3 {
|
||||
if thresholdTokens[i].Kind != dnfpvf.TokenInt {
|
||||
return nil, errors.New("disjointer experience threshold is invalid")
|
||||
}
|
||||
config.ExperienceThresholds = append(config.ExperienceThresholds, thresholdTokens[i].Int)
|
||||
}
|
||||
if err := parseInt64Pairs(doc.Ints("upgrade cost"), config.UpgradeCosts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etc, err := readDocument(c.source, "character/expertjob.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limits := map[int]int64{}
|
||||
if err := parseInt64Pairs(etc.Ints("expertjob level limit"), limits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for grade, level := range limits {
|
||||
config.CharacterLevelLimits[grade] = int(level)
|
||||
}
|
||||
if config.InitialEndurance <= 0 || config.MaximumStoreCharge < 0 || len(config.GiveUpCosts) == 0 || config.BaseConst <= 0 || config.EnduranceReduceMin < 0 ||
|
||||
config.EnduranceReduceMax < config.EnduranceReduceMin || config.ExperienceGainMin < 0 || config.ExperienceGainMax < config.ExperienceGainMin ||
|
||||
len(config.Results) == 0 || len(config.RepairRules) == 0 || len(config.RepairRules) != len(config.ExperienceThresholds) ||
|
||||
len(config.UpgradeCosts) != len(config.RepairRules) || len(config.CharacterLevelLimits) < len(config.RepairRules) {
|
||||
return nil, errors.New("disjointer configuration is incomplete")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func parseDisjointerSelections(values []float64, target map[int][]DisjointerSelectionRule) error {
|
||||
if len(values)%6 != 0 {
|
||||
return errors.New("disjointer selection row width is invalid")
|
||||
}
|
||||
for i := 0; i < len(values); i += 6 {
|
||||
table := int(values[i])
|
||||
rule := DisjointerSelectionRule{int(values[i+1]), int(values[i+2]), int64(values[i+3]), int(values[i+4]), values[i+5]}
|
||||
if table <= 0 || rule.MinimumGrade < 0 || rule.MaximumGrade < rule.MinimumGrade || rule.ItemID <= 0 || rule.Weight <= 0 || rule.CountDivisor <= 0 {
|
||||
return errors.New("disjointer selection is invalid")
|
||||
}
|
||||
target[table] = append(target[table], rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInt64Pairs(values []int64, target map[int]int64) error {
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return errors.New("expert job pair row width is invalid")
|
||||
}
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, value := int(values[i]), values[i+1]
|
||||
if key <= 0 || value < 0 {
|
||||
return errors.New("expert job pair is invalid")
|
||||
}
|
||||
if _, duplicate := target[key]; duplicate {
|
||||
return errors.New("expert job pair is duplicated")
|
||||
}
|
||||
target[key] = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectDisjointerRule(values []DisjointerSelectionRule, grade int, rng randomSource) (DisjointerSelectionRule, bool) {
|
||||
total := 0
|
||||
eligible := make([]DisjointerSelectionRule, 0, len(values))
|
||||
for _, value := range values {
|
||||
if grade >= value.MinimumGrade && grade <= value.MaximumGrade {
|
||||
eligible = append(eligible, value)
|
||||
total += value.Weight
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return DisjointerSelectionRule{}, false
|
||||
}
|
||||
roll := rng.Intn(total)
|
||||
for _, value := range eligible {
|
||||
if roll < value.Weight {
|
||||
return value, true
|
||||
}
|
||||
roll -= value.Weight
|
||||
}
|
||||
return DisjointerSelectionRule{}, false
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type RepairRule struct {
|
||||
FullRepairCost int64
|
||||
MaxEndurance int64
|
||||
}
|
||||
|
||||
type EnchanterCardRecipe struct {
|
||||
Qualification int
|
||||
RecipeItemID int64
|
||||
RequiredLevel int
|
||||
Materials []RecipeEntry
|
||||
}
|
||||
|
||||
type EnchanterCard struct {
|
||||
Qualification int
|
||||
BindChance int
|
||||
}
|
||||
|
||||
type EnchanterCardExperienceRule struct {
|
||||
Level int
|
||||
SuccessRates [5]int
|
||||
ExtraRate int
|
||||
MinimumExperienceGain int
|
||||
MaximumExperienceGain int
|
||||
}
|
||||
|
||||
type EnchanterConfig struct {
|
||||
Recipes *Config
|
||||
MaximumStoreCharge int64
|
||||
InitialEndurance int64
|
||||
EnduranceReduction int64
|
||||
EnduranceMinimumLevel int
|
||||
RepairRules []RepairRule
|
||||
QualificationRequiredLevel map[int]int
|
||||
CardRecipes map[int64]EnchanterCardRecipe
|
||||
Cards map[int64]EnchanterCard
|
||||
BeadByCard map[int64]int64
|
||||
CardExperienceByLevel map[int]EnchanterCardExperienceRule
|
||||
}
|
||||
|
||||
func (c *EnchanterConfig) Qualifications(experience int64) []byte {
|
||||
if c == nil || c.Recipes == nil {
|
||||
return nil
|
||||
}
|
||||
level := c.Recipes.Level(experience)
|
||||
values := make([]int, 0, len(c.QualificationRequiredLevel))
|
||||
for qualification, required := range c.QualificationRequiredLevel {
|
||||
if qualification >= 0 && qualification <= math.MaxUint8 && required <= level {
|
||||
values = append(values, qualification)
|
||||
}
|
||||
}
|
||||
sort.Ints(values)
|
||||
out := make([]byte, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = byte(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *EnchanterConfig) StoreSkills(experience int64) []byte {
|
||||
if c == nil || c.Recipes == nil {
|
||||
return nil
|
||||
}
|
||||
level := c.Recipes.Level(experience)
|
||||
values := make([]int, 0, len(c.Recipes.Skills))
|
||||
for skill, required := range c.Recipes.Skills {
|
||||
if skill > 0 && skill <= math.MaxUint8 && required <= level {
|
||||
values = append(values, skill)
|
||||
}
|
||||
}
|
||||
sort.Ints(values)
|
||||
out := make([]byte, len(values))
|
||||
for i, value := range values {
|
||||
out[i] = byte(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *EnchanterConfig) RepairRule(experience int64) (RepairRule, bool) {
|
||||
if c == nil || c.Recipes == nil {
|
||||
return RepairRule{}, false
|
||||
}
|
||||
index := c.Recipes.Level(experience) - 1
|
||||
if index < 0 || index >= len(c.RepairRules) {
|
||||
return RepairRule{}, false
|
||||
}
|
||||
return c.RepairRules[index], true
|
||||
}
|
||||
|
||||
func (c *EnchanterConfig) KnowsRecipe(recipeID int64) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := c.CardRecipes[recipeID]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := c.Recipes.Recipes[recipeID]
|
||||
return ok
|
||||
}
|
||||
|
||||
type EnchanterBeadPlan struct {
|
||||
Recipe EnchanterCardRecipe
|
||||
Card EnchanterCard
|
||||
BeadItemID int64
|
||||
Success bool
|
||||
ExperienceGain int64
|
||||
FinalExperience int64
|
||||
LevelChanged bool
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanEnchanterBead(experience, recipeID, cardID int64, rng RandomFunc) (EnchanterBeadPlan, error) {
|
||||
config, ok := c.Enchanter()
|
||||
if !ok {
|
||||
return EnchanterBeadPlan{}, ErrJobUnsupported
|
||||
}
|
||||
recipe, recipeOK := config.CardRecipes[recipeID]
|
||||
card, cardOK := config.Cards[cardID]
|
||||
bead, beadOK := config.BeadByCard[cardID]
|
||||
level := config.Recipes.Level(experience)
|
||||
rule, ruleOK := config.CardExperienceByLevel[level]
|
||||
rateIndex, rateOK := enchanterCardSuccessRateIndex(recipe.Qualification, len(rule.SuccessRates))
|
||||
if !recipeOK || !cardOK || !beadOK || !ruleOK || recipe.Qualification != card.Qualification ||
|
||||
level < recipe.RequiredLevel || !rateOK {
|
||||
return EnchanterBeadPlan{}, ErrRecipeUnavailable
|
||||
}
|
||||
random := randomSource(RandomFunc(rng))
|
||||
plan := EnchanterBeadPlan{Recipe: recipe, Card: card, BeadItemID: bead}
|
||||
plan.Success = random.Intn(100) < rule.SuccessRates[rateIndex]
|
||||
if plan.Success {
|
||||
gain := rule.MinimumExperienceGain
|
||||
if rule.MaximumExperienceGain > gain {
|
||||
gain += random.Intn(rule.MaximumExperienceGain - gain + 1)
|
||||
}
|
||||
plan.ExperienceGain = int64(gain)
|
||||
}
|
||||
plan.FinalExperience = saturatingAdd(experience, plan.ExperienceGain)
|
||||
plan.LevelChanged = config.Recipes.Level(experience) != config.Recipes.Level(plan.FinalExperience)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
type EnchanterStorePlan struct {
|
||||
Recipe EnchanterCardRecipe
|
||||
Success bool
|
||||
EnduranceReduction int64
|
||||
ExperienceGain int64
|
||||
FinalExperience int64
|
||||
LevelChanged bool
|
||||
}
|
||||
|
||||
func (c *Catalog) PlanEnchanterStore(experience, recipeID, cardID, endurance int64, rng RandomFunc) (EnchanterStorePlan, error) {
|
||||
config, ok := c.Enchanter()
|
||||
if !ok {
|
||||
return EnchanterStorePlan{}, ErrJobUnsupported
|
||||
}
|
||||
recipe, recipeOK := config.CardRecipes[recipeID]
|
||||
card, cardOK := config.Cards[cardID]
|
||||
level := config.Recipes.Level(experience)
|
||||
rule, ruleOK := config.CardExperienceByLevel[level]
|
||||
rateIndex, rateOK := enchanterCardSuccessRateIndex(recipe.Qualification, len(rule.SuccessRates))
|
||||
if !recipeOK || !cardOK || !ruleOK || recipe.Qualification != card.Qualification || level < recipe.RequiredLevel || !rateOK {
|
||||
return EnchanterStorePlan{}, ErrRecipeUnavailable
|
||||
}
|
||||
reduction := int64(0)
|
||||
if level >= config.EnduranceMinimumLevel {
|
||||
reduction = config.EnduranceReduction
|
||||
}
|
||||
if endurance < reduction {
|
||||
return EnchanterStorePlan{}, ErrMachineEndurance
|
||||
}
|
||||
random := randomSource(RandomFunc(rng))
|
||||
plan := EnchanterStorePlan{Recipe: recipe, EnduranceReduction: reduction}
|
||||
plan.Success = random.Intn(100) < rule.SuccessRates[rateIndex]
|
||||
if plan.Success {
|
||||
gain := rule.MinimumExperienceGain
|
||||
if rule.MaximumExperienceGain > gain {
|
||||
gain += random.Intn(rule.MaximumExperienceGain - gain + 1)
|
||||
}
|
||||
plan.ExperienceGain = int64(gain)
|
||||
}
|
||||
plan.FinalExperience = saturatingAdd(experience, plan.ExperienceGain)
|
||||
plan.LevelChanged = config.Recipes.Level(experience) != config.Recipes.Level(plan.FinalExperience)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// Current Script.pvf has five success-rate columns but uses both
|
||||
// qualification 4 and the later high presentation grade 6. Only that exact
|
||||
// active-PVF extension shares the final column; unknown future qualification
|
||||
// values remain fail closed while recipe/card qualification equality stays
|
||||
// exact.
|
||||
func enchanterCardSuccessRateIndex(qualification, width int) (int, bool) {
|
||||
if qualification >= 0 && qualification < width {
|
||||
return qualification, true
|
||||
}
|
||||
if width == 5 && qualification == 6 {
|
||||
return 4, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (c *Catalog) loadEnchanterConfig(recipes *Config) (*EnchanterConfig, error) {
|
||||
if recipes == nil {
|
||||
return nil, ErrCatalogUnavailable
|
||||
}
|
||||
doc, err := readDocument(c.source, EnchanterPVFPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &EnchanterConfig{
|
||||
Recipes: recipes, QualificationRequiredLevel: map[int]int{}, CardRecipes: map[int64]EnchanterCardRecipe{},
|
||||
Cards: map[int64]EnchanterCard{}, BeadByCard: map[int64]int64{}, CardExperienceByLevel: map[int]EnchanterCardExperienceRule{},
|
||||
}
|
||||
if value, ok := doc.Int("limit store charge"); ok {
|
||||
config.MaximumStoreCharge = value
|
||||
}
|
||||
if value, ok := doc.Int("endurance initial value"); ok {
|
||||
config.InitialEndurance = value
|
||||
}
|
||||
reduction := doc.Ints("endurance reduce")
|
||||
if len(reduction) != 2 {
|
||||
return nil, errors.New("enchanter [endurance reduce] is invalid")
|
||||
}
|
||||
config.EnduranceMinimumLevel, config.EnduranceReduction = int(reduction[0]), reduction[1]
|
||||
repairs := doc.Ints("endurance repair cost")
|
||||
if len(repairs) == 0 || len(repairs)%2 != 0 {
|
||||
return nil, errors.New("enchanter [endurance repair cost] is invalid")
|
||||
}
|
||||
for i := 0; i < len(repairs); i += 2 {
|
||||
if repairs[i] <= 0 || repairs[i+1] <= 0 {
|
||||
return nil, errors.New("enchanter repair rule is invalid")
|
||||
}
|
||||
config.RepairRules = append(config.RepairRules, RepairRule{repairs[i], repairs[i+1]})
|
||||
}
|
||||
if err := c.parseEnchanterRarityRecipes(doc, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bind := doc.Ints("monstercard bind info")
|
||||
if len(bind) == 0 || len(bind)%2 != 0 {
|
||||
return nil, fmt.Errorf("enchanter [monstercard bind info] is invalid: values=%d", len(bind))
|
||||
}
|
||||
for i := 0; i < len(bind); i += 2 {
|
||||
qualification, chance := int(bind[i]), int(bind[i+1])
|
||||
if qualification < 0 || chance < 0 || chance > 1000 {
|
||||
return nil, fmt.Errorf("enchanter bind rule is invalid: qualification=%d chance=%d", qualification, chance)
|
||||
}
|
||||
}
|
||||
rules := doc.Ints("monstercard exp")
|
||||
if len(rules) == 0 || len(rules)%9 != 0 {
|
||||
return nil, errors.New("enchanter [monstercard exp] is invalid")
|
||||
}
|
||||
for i := 0; i < len(rules); i += 9 {
|
||||
rule := EnchanterCardExperienceRule{Level: int(rules[i]), ExtraRate: int(rules[i+6]), MinimumExperienceGain: int(rules[i+7]), MaximumExperienceGain: int(rules[i+8])}
|
||||
for j := range rule.SuccessRates {
|
||||
rule.SuccessRates[j] = int(rules[i+1+j])
|
||||
if rule.SuccessRates[j] < 0 || rule.SuccessRates[j] > 100 {
|
||||
return nil, errors.New("enchanter card success rate is invalid")
|
||||
}
|
||||
}
|
||||
if rule.Level <= 0 || rule.ExtraRate < 0 || rule.ExtraRate > 100 || rule.MinimumExperienceGain < 0 || rule.MaximumExperienceGain < rule.MinimumExperienceGain {
|
||||
return nil, errors.New("enchanter card experience rule is invalid")
|
||||
}
|
||||
config.CardExperienceByLevel[rule.Level] = rule
|
||||
}
|
||||
if config.MaximumStoreCharge < 0 || config.InitialEndurance <= 0 || config.EnduranceReduction <= 0 || config.EnduranceMinimumLevel <= 0 ||
|
||||
len(config.RepairRules) != len(recipes.ExperienceThresholds) || len(config.CardRecipes) == 0 || len(config.Cards) == 0 ||
|
||||
len(config.BeadByCard) == 0 || len(config.CardExperienceByLevel) != len(recipes.ExperienceThresholds) {
|
||||
return nil, errors.New("enchanter configuration is incomplete")
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) parseEnchanterRarityRecipes(doc *dnfpvf.Document, config *EnchanterConfig) error {
|
||||
active := false
|
||||
for index := 0; index < len(doc.Sections); index++ {
|
||||
name := strings.ToLower(strings.TrimSpace(doc.Sections[index].Name))
|
||||
switch name {
|
||||
case "rarity recipe":
|
||||
active = true
|
||||
continue
|
||||
case "/rarity recipe":
|
||||
return nil
|
||||
}
|
||||
if !active || name != "rarity" {
|
||||
continue
|
||||
}
|
||||
qualificationValues := sectionInts(doc, doc.Sections[index])
|
||||
if len(qualificationValues) != 1 {
|
||||
return errors.New("enchanter [rarity] is invalid")
|
||||
}
|
||||
qualification := int(qualificationValues[0])
|
||||
var recipeID, required int64
|
||||
var baseResults []int64
|
||||
for index++; index < len(doc.Sections); index++ {
|
||||
childName := strings.ToLower(strings.TrimSpace(doc.Sections[index].Name))
|
||||
if childName == "/rarity" {
|
||||
break
|
||||
}
|
||||
values := sectionInts(doc, doc.Sections[index])
|
||||
switch childName {
|
||||
case "recipe":
|
||||
if len(values) == 1 {
|
||||
recipeID = values[0]
|
||||
}
|
||||
case "expert job level":
|
||||
if len(values) == 1 {
|
||||
required = values[0]
|
||||
}
|
||||
case "base result":
|
||||
baseResults = values
|
||||
}
|
||||
}
|
||||
if qualification < 0 || qualification > math.MaxUint8 || recipeID <= 0 || required <= 0 || len(baseResults) == 0 || len(baseResults)%2 != 0 {
|
||||
return errors.New("enchanter rarity recipe is invalid")
|
||||
}
|
||||
ref, found := c.items[recipeID]
|
||||
if !found || ref.kind != itemStackable {
|
||||
return fmt.Errorf("enchanter card recipe item=%d is unavailable", recipeID)
|
||||
}
|
||||
recipeDoc, err := c.itemDocument(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobOnly := normalizeTag(firstText(recipeDoc, "expertjob only"))
|
||||
jobLevel, levelFound := recipeDoc.Int("expertjob only")
|
||||
needSkill := recipeDoc.Ints("need skill")
|
||||
// Card-to-bead recipes split their costs across [input item] and
|
||||
// [bead item]. The latter is the rarity soul/essence cost (for example
|
||||
// unique recipes consume 2610041 x3); omitting it let the compound
|
||||
// succeed without consuming the PVF-authoritative soul material.
|
||||
materialValues := append([]int64(nil), recipeDoc.Ints("input item")...)
|
||||
materialValues = append(materialValues, recipeDoc.Ints("bead item")...)
|
||||
materials := positivePairs(materialValues)
|
||||
if !strings.Contains(normalizeTag(firstText(recipeDoc, "stackable type")), "recipe") || jobOnly != "enchanter" || !levelFound || jobLevel != required ||
|
||||
len(needSkill) < 2 || needSkill[1] <= 0 || config.Recipes.Skills[int(needSkill[0])] == 0 || len(materials) == 0 {
|
||||
return errors.New("enchanter card recipe metadata is invalid")
|
||||
}
|
||||
config.QualificationRequiredLevel[qualification] = int(required)
|
||||
config.CardRecipes[recipeID] = EnchanterCardRecipe{qualification, recipeID, int(required), materials}
|
||||
for i := 0; i < len(baseResults); i += 2 {
|
||||
cardID, beadID := baseResults[i], baseResults[i+1]
|
||||
if cardID <= 0 || beadID <= 0 {
|
||||
return errors.New("enchanter bead result is invalid")
|
||||
}
|
||||
if _, duplicate := config.BeadByCard[cardID]; duplicate {
|
||||
return errors.New("enchanter bead result is duplicated")
|
||||
}
|
||||
ref, found := c.items[cardID]
|
||||
config.BeadByCard[cardID] = beadID
|
||||
if found && ref.kind == itemStackable {
|
||||
cardDoc, loadErr := c.itemDocument(ref)
|
||||
if loadErr == nil && normalizeTag(strings.Join(cardDoc.Texts("item category"), " ")) == "monster card" {
|
||||
config.Cards[cardID] = EnchanterCard{Qualification: qualification}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.New("enchanter [rarity recipe] is not closed")
|
||||
}
|
||||
|
||||
func sectionInts(doc *dnfpvf.Document, section dnfpvf.Section) []int64 {
|
||||
if doc == nil || section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
|
||||
return nil
|
||||
}
|
||||
values := make([]int64, 0, section.End-section.Start)
|
||||
for _, token := range doc.Tokens[section.Start:section.End] {
|
||||
if token.Kind == dnfpvf.TokenInt {
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func positivePairs(values []int64) []RecipeEntry {
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
merged := map[int64]int64{}
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
if values[i] <= 0 || values[i+1] <= 0 || merged[values[i]] > math.MaxInt64-values[i+1] {
|
||||
return nil
|
||||
}
|
||||
merged[values[i]] += values[i+1]
|
||||
}
|
||||
keys := make([]int64, 0, len(merged))
|
||||
for itemID := range merged {
|
||||
keys = append(keys, itemID)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
||||
out := make([]RecipeEntry, 0, len(keys))
|
||||
for _, itemID := range keys {
|
||||
out = append(out, RecipeEntry{itemID, merged[itemID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package expertjob
|
||||
|
||||
import "math"
|
||||
|
||||
// GiveUpPlan is the PVF-backed wallet and escalation result of abandoning an
|
||||
// expert job. State is the cost tier used by the next abandonment.
|
||||
type GiveUpPlan struct {
|
||||
FinalGold int64
|
||||
Cost int64
|
||||
FinalState byte
|
||||
}
|
||||
|
||||
func PlanGiveUp(gold, currentState int64, costs []int64) (GiveUpPlan, error) {
|
||||
if gold < 0 || currentState < 0 || currentState > math.MaxUint8 || len(costs) == 0 {
|
||||
return GiveUpPlan{}, ErrMachineInvalid
|
||||
}
|
||||
index := int(currentState)
|
||||
if index >= len(costs) {
|
||||
index = len(costs) - 1
|
||||
}
|
||||
cost := costs[index]
|
||||
if cost < 0 {
|
||||
return GiveUpPlan{}, ErrMachineInvalid
|
||||
}
|
||||
if gold < cost {
|
||||
return GiveUpPlan{}, ErrInsufficientGold
|
||||
}
|
||||
finalState := index
|
||||
if index+1 < len(costs) {
|
||||
finalState++
|
||||
}
|
||||
if finalState > math.MaxUint8 {
|
||||
finalState = math.MaxUint8
|
||||
}
|
||||
return GiveUpPlan{FinalGold: gold - cost, Cost: cost, FinalState: byte(finalState)}, nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("expert job owner is unavailable")
|
||||
ErrCharacterRequired = errors.New("expert job character is required")
|
||||
ErrCharacterNotFound = errors.New("expert job character is not found")
|
||||
ErrAccountMismatch = errors.New("expert job character account mismatch")
|
||||
ErrInventoryNotFound = errors.New("expert job inventory is not found")
|
||||
ErrProjectorRequired = errors.New("expert job projector is required")
|
||||
)
|
||||
|
||||
type Assets struct {
|
||||
Character *dnfrepo.CharacterRecord
|
||||
AccountInventory *dnfrepo.AccountInventoryRecord
|
||||
Inventory *dnfrepo.InventoryRecord
|
||||
Quest *dnfrepo.QuestRecord
|
||||
}
|
||||
|
||||
type Changes struct {
|
||||
Character bool
|
||||
AccountInventory bool
|
||||
Inventory bool
|
||||
Quest bool
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
UpdatedAt time.Time
|
||||
IncludeQuest bool
|
||||
Project func(*Assets) (Changes, error)
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
settlements dnfrepo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{settlements: repositories.CharacterSettlement}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Compound(ctx context.Context, command Command) error {
|
||||
return o.mutate(ctx, "compound", command)
|
||||
}
|
||||
|
||||
func (o *Owner) Extract(ctx context.Context, command Command) error {
|
||||
return o.mutate(ctx, "extract", command)
|
||||
}
|
||||
|
||||
func (o *Owner) LearnRecipe(ctx context.Context, command Command) error {
|
||||
return o.mutate(ctx, "learn-recipe", command)
|
||||
}
|
||||
|
||||
func (o *Owner) Machine(ctx context.Context, command Command) error {
|
||||
return o.mutate(ctx, "machine", command)
|
||||
}
|
||||
|
||||
func (o *Owner) GiveUp(ctx context.Context, command Command) error {
|
||||
command.IncludeQuest = true
|
||||
return o.mutate(ctx, "give-up", command)
|
||||
}
|
||||
|
||||
func (o *Owner) mutate(ctx context.Context, operation string, command Command) error {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return ErrCharacterRequired
|
||||
}
|
||||
if command.Project == nil {
|
||||
return ErrProjectorRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := command.UpdatedAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
return o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Inventory == nil || tx.AccountInventory == nil || (command.IncludeQuest && tx.Quest == nil) {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s operation=%s", ErrCharacterNotFound, characterID, operation)
|
||||
}
|
||||
if accountID == "" {
|
||||
accountID = strings.TrimSpace(character.AccountID)
|
||||
}
|
||||
if strings.TrimSpace(character.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: account=%s character=%s operation=%s", ErrAccountMismatch, accountID, characterID, operation)
|
||||
}
|
||||
inventory, found, err := tx.Inventory.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID || inventory.Slots == nil {
|
||||
return fmt.Errorf("%w: character=%s operation=%s", ErrInventoryNotFound, characterID, operation)
|
||||
}
|
||||
accountInventory, _, err := tx.AccountInventory.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
accountInventory = dnfrepo.CloneAccountInventory(accountInventory)
|
||||
accountInventory.AccountID = accountID
|
||||
if accountInventory.Slots == nil {
|
||||
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
var quest *dnfrepo.QuestRecord
|
||||
if command.IncludeQuest {
|
||||
record, _, loadErr := tx.Quest.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
record = dnfrepo.CloneQuest(record)
|
||||
record.CharacterID = characterID
|
||||
if record.States == nil {
|
||||
record.States = make(map[int64]dnfrepo.QuestState)
|
||||
}
|
||||
if record.Progress == nil {
|
||||
record.Progress = make(map[int64]dnfrepo.QuestState)
|
||||
}
|
||||
quest = &record
|
||||
}
|
||||
changes, err := command.Project(&Assets{
|
||||
Character: &character,
|
||||
AccountInventory: &accountInventory,
|
||||
Inventory: &inventory,
|
||||
Quest: quest,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changes.Character {
|
||||
character.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.Inventory {
|
||||
inventory.UpdatedAt = now
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.AccountInventory {
|
||||
accountInventory.UpdatedAt = now
|
||||
if err := tx.AccountInventory.Save(ctx, accountInventory); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.Quest {
|
||||
if quest == nil {
|
||||
return ErrProjectorRequired
|
||||
}
|
||||
quest.UpdatedAt = now
|
||||
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, *quest, dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package expertjob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerCompoundCommitsCharacterAndInventoryAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Stats: map[string]int64{"expert_job_exp": 0}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:9": {ItemID: 3001, Count: 2}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = owner.Compound(ctx, Command{AccountID: "dnf:1", CharacterID: "19", Project: func(assets *Assets) (Changes, error) {
|
||||
assets.Character.Stats["expert_job_exp"] = 3
|
||||
delete(assets.Inventory.Slots, "0:9")
|
||||
return Changes{Character: true, Inventory: true}, nil
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character, _, _ := repositories.Character.Load(ctx, "19")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
|
||||
if character.Stats["expert_job_exp"] != 3 || len(inventory.Slots) != 0 {
|
||||
t.Fatalf("character=%+v inventory=%+v", character, inventory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerLearnRecipeRollsBackBothAggregates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Stats: map[string]int64{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:9": {ItemID: 1002, Count: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
want := errors.New("late failure")
|
||||
err := owner.LearnRecipe(ctx, Command{AccountID: "dnf:1", CharacterID: "19", Project: func(assets *Assets) (Changes, error) {
|
||||
if assets.Character.Stats == nil {
|
||||
assets.Character.Stats = make(map[string]int64)
|
||||
}
|
||||
assets.Character.Stats["expert_job_recipe_2_1002"] = 1
|
||||
delete(assets.Inventory.Slots, "0:9")
|
||||
return Changes{Character: true, Inventory: true}, want
|
||||
}})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
character, _, _ := repositories.Character.Load(ctx, "19")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
|
||||
if character.Stats["expert_job_recipe_2_1002"] != 0 || inventory.Slots["0:9"].Count != 1 {
|
||||
t.Fatalf("rollback character=%+v inventory=%+v", character, inventory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerGiveUpCommitsCharacterWalletAndQuestAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Stats: map[string]int64{"expert_job_type": 3, "expert_job_exp": 20}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:0": {ItemID: 0, Count: 60_000}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{2702: {Status: "completed"}, 5000: {Status: "active"}}, Progress: map[int64]dnfrepo.QuestState{2708: {Status: "active"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = owner.GiveUp(ctx, Command{AccountID: "dnf:1", CharacterID: "19", Project: func(assets *Assets) (Changes, error) {
|
||||
assets.Character.Stats["expert_job_type"] = 0
|
||||
assets.Character.Stats["expert_job_exp"] = 0
|
||||
wallet := assets.Inventory.Slots["0:0"]
|
||||
wallet.Count = 59_000
|
||||
assets.Inventory.Slots["0:0"] = wallet
|
||||
delete(assets.Quest.States, 2702)
|
||||
delete(assets.Quest.Progress, 2708)
|
||||
return Changes{Character: true, Inventory: true, Quest: true}, nil
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character, _, _ := repositories.Character.Load(ctx, "19")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
|
||||
quest, _, _ := repositories.Quest.Load(ctx, "19")
|
||||
if character.Stats["expert_job_type"] != 0 || inventory.Slots["0:0"].Count != 59_000 {
|
||||
t.Fatalf("character=%+v inventory=%+v", character, inventory)
|
||||
}
|
||||
if _, exists := quest.States[2702]; exists {
|
||||
t.Fatalf("transition state was not removed: %+v", quest)
|
||||
}
|
||||
if _, exists := quest.Progress[2708]; exists {
|
||||
t.Fatalf("transition progress was not removed: %+v", quest)
|
||||
}
|
||||
if quest.States[5000].Status != "active" {
|
||||
t.Fatalf("unrelated quest was changed: %+v", quest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerGiveUpRollsBackQuestWithWallet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
_ = repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Stats: map[string]int64{"expert_job_type": 2}})
|
||||
_ = repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{"0:0": {ItemID: 0, Count: 60_000}}})
|
||||
_ = repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{2702: {Status: "completed"}}})
|
||||
owner, _ := NewOwner(repositories)
|
||||
want := errors.New("late give-up failure")
|
||||
err := owner.GiveUp(ctx, Command{AccountID: "dnf:1", CharacterID: "19", Project: func(assets *Assets) (Changes, error) {
|
||||
assets.Character.Stats["expert_job_type"] = 0
|
||||
wallet := assets.Inventory.Slots["0:0"]
|
||||
wallet.Count = 59_000
|
||||
assets.Inventory.Slots["0:0"] = wallet
|
||||
delete(assets.Quest.States, 2702)
|
||||
return Changes{Character: true, Inventory: true, Quest: true}, want
|
||||
}})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
character, _, _ := repositories.Character.Load(ctx, "19")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
|
||||
quest, _, _ := repositories.Quest.Load(ctx, "19")
|
||||
if character.Stats["expert_job_type"] != 2 || inventory.Slots["0:0"].Count != 60_000 || quest.States[2702].Status != "completed" {
|
||||
t.Fatalf("rollback character=%+v inventory=%+v quest=%+v", character, inventory, quest)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user