DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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