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