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

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

897 lines
31 KiB
Go

package dnfbridge
import (
cryptorand "crypto/rand"
"math"
"math/big"
"strconv"
"strings"
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// currentDisjointPVFConfigCached memoizes the parsed disjoint config on the
// service. The config is immutable for a given PVF archive, but the raw
// loader re-tokenizes etc/disjoint.etc, etc/emblemcompound.etc and every
// emblemlistinfo_<grade>.etc document on every call; before memoization that
// ran inside each equipment/avatar disjoint, emblem compound and expert-store
// disjoint request. A load failure is memoized too, and returned without
// touching the nil config pointer.
func (s *Service) currentDisjointPVFConfigCached(source dnfpvf.Source) (currentDisjointPVFConfig, error) {
if s == nil || source == nil {
return currentDisjointPVFConfig{}, errCurrentDisjointUnavailable
}
s.disjointConfigMu.Lock()
defer s.disjointConfigMu.Unlock()
if s.disjointPVFConfig != nil {
return *s.disjointPVFConfig, nil
}
if s.disjointPVFConfigErr != nil {
return currentDisjointPVFConfig{}, s.disjointPVFConfigErr
}
config, err := loadCurrentDisjointPVFConfig(source)
if err != nil {
s.disjointPVFConfigErr = err
return currentDisjointPVFConfig{}, err
}
s.disjointPVFConfig = &config
return config, nil
}
func loadCurrentDisjointPVFConfig(source dnfpvf.Source) (currentDisjointPVFConfig, error) {
if source == nil {
return currentDisjointPVFConfig{}, errCurrentDisjointUnavailable
}
disjoint, err := parseDungeonCardPVFDocument(source, "etc/disjoint.etc")
if err != nil {
return currentDisjointPVFConfig{}, err
}
config := currentDisjointPVFConfig{
CubeBase: 150,
IndependentByItem: make(map[uint32][]currentDisjointReward),
AvatarByJob: make(map[string][]uint32),
EmblemBoosters: make(map[[2]int]uint32),
}
priceTable, err := parseDungeonCardPVFDocument(source, "equipment/pricetable.tbl")
if err != nil {
return currentDisjointPVFConfig{}, err
}
rates := priceTable.Ints("rate")
if len(rates) == 0 || rates[0] <= 0 || rates[0] > math.MaxInt32 {
return currentDisjointPVFConfig{}, errCurrentDisjointRewardInvalid
}
config.EquipmentSellRate = rates[0]
for _, section := range disjoint.Sections {
if !strings.EqualFold(strings.TrimSpace(section.Name), "cube index") {
continue
}
tokens := currentDisjointSectionTokens(disjoint, section)
if len(tokens) == 0 || !strings.EqualFold(strings.TrimSpace(strings.Trim(tokens[0].Value, "`")), "[no element]") {
continue
}
for _, token := range tokens[1:] {
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint32 {
config.CubeItemID = uint32(token.Int)
break
}
}
}
values := disjoint.Numbers("cube creation const")
if len(values) > 0 && values[0] > 0 {
config.CubeBase = values[0]
config.CubeMultipliers = append(config.CubeMultipliers, values[1:]...)
}
config.Additional = currentDisjointParseAdditional(disjoint.Ints("additional result"))
config.AdditionalConsts = currentDisjointParseAdditionalConsts(disjoint.Numbers("additional result const"), config.Additional)
config.Expands = currentDisjointParseExpands(disjoint.Ints("additional result expand"), disjoint.Numbers("additional result expand const"), len(config.Additional))
config.IndependentByItem = currentDisjointParseIndependentResults(disjoint)
config.AvatarByJob = currentDisjointParseAvatarByJob(disjoint)
config.AvatarEmblemTables = currentDisjointLoadAvatarEmblemTables(source)
emblem, err := parseDungeonCardPVFDocument(source, "etc/emblemcompound.etc")
if err != nil {
return currentDisjointPVFConfig{}, err
}
config.EmblemBoosters = currentDisjointParseEmblemBoosters(emblem.Ints("emblem compound info"))
if config.CubeItemID == 0 || config.EquipmentSellRate <= 0 || len(config.CubeMultipliers) == 0 ||
len(config.Additional) != len(config.CubeMultipliers) || len(config.AdditionalConsts) != len(config.Additional) ||
len(config.Expands) != len(config.Additional) || len(config.AvatarByJob) == 0 || len(config.EmblemBoosters) == 0 {
return currentDisjointPVFConfig{}, errCurrentDisjointRewardInvalid
}
return config, nil
}
// currentDisjointParseIndependentResults reads the active PVF's exact-item
// additive result table. The current file contains only deterministic 1000
// per-mille rows; fail closed on any other shape instead of inventing a roll
// policy for a different client/PVF compatibility unit.
func currentDisjointParseIndependentResults(document *dnfpvf.Document) map[uint32][]currentDisjointReward {
out := make(map[uint32][]currentDisjointReward)
if document == nil {
return out
}
active := false
inParticular := false
var inputs []uint32
var outputs []currentDisjointReward
valid := true
commit := func() {
if !inParticular || !valid || len(inputs) == 0 || len(outputs) == 0 {
return
}
for _, itemID := range inputs {
out[itemID] = append([]currentDisjointReward(nil), outputs...)
}
}
for _, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
switch name {
case "independent result":
active = true
continue
case "/independent result":
commit()
return out
}
if !active {
continue
}
switch name {
case "particular item":
commit()
inParticular = true
inputs = nil
outputs = nil
valid = true
case "/particular item":
commit()
inParticular = false
inputs = nil
outputs = nil
case "input index":
if !inParticular {
valid = false
continue
}
for _, value := range currentDisjointTokensInts(currentDisjointSectionTokens(document, section)) {
if value <= 0 || value > math.MaxUint32 {
valid = false
continue
}
inputs = append(inputs, uint32(value))
}
case "output index":
if !inParticular {
valid = false
continue
}
values := currentDisjointTokensInts(currentDisjointSectionTokens(document, section))
if len(values) == 0 || len(values)%3 != 0 {
valid = false
continue
}
for offset := 0; offset+2 < len(values); offset += 3 {
itemID, probability, count := values[offset], values[offset+1], values[offset+2]
if itemID <= 0 || itemID > math.MaxUint32 || probability != 1000 || count <= 0 || count > math.MaxUint32 {
valid = false
continue
}
outputs = append(outputs, currentDisjointReward{ItemID: uint32(itemID), Count: uint32(count)})
}
}
}
commit()
return out
}
func currentDisjointSectionTokens(document *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
if document == nil || section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
return nil
}
return document.Tokens[section.Start:section.End]
}
func currentDisjointParseAdditional(values []int64) [][]uint32 {
out := make([][]uint32, 0)
for offset := 0; offset < len(values); {
count := int(values[offset])
offset++
if count < 0 || offset+count > len(values) {
return nil
}
row := make([]uint32, 0, count)
for _, value := range values[offset : offset+count] {
if value > 0 && value <= math.MaxUint32 {
row = append(row, uint32(value))
}
}
offset += count
out = append(out, row)
}
return out
}
func currentDisjointParseAdditionalConsts(values []float64, additional [][]uint32) []currentDisjointAdditionalConst {
out := make([]currentDisjointAdditionalConst, 0, len(additional))
offset := 0
for _, candidates := range additional {
if len(candidates) == 0 {
if offset >= len(values) {
return nil
}
// Disabled rarity rows retain one placeholder in the PVF.
out = append(out, currentDisjointAdditionalConst{})
offset++
continue
}
if offset+3 >= len(values) {
return nil
}
out = append(out, currentDisjointAdditionalConst{
GreatDivisor: values[offset], NormalDivisor: values[offset+1],
GreatChance: values[offset+2], NormalChance: values[offset+3],
})
offset += 4
}
if offset != len(values) {
return nil
}
return out
}
func currentDisjointParseExpands(ids []int64, values []float64, rows int) []currentDisjointExpand {
if rows <= 0 {
return nil
}
out := make([]currentDisjointExpand, 0, rows)
idOffset := 0
for row := 0; row < rows; row++ {
if idOffset >= len(ids) {
return nil
}
count := int(ids[idOffset])
idOffset++
if count < 0 || idOffset+count > len(ids) {
return nil
}
expand := currentDisjointExpand{}
if count > 0 {
itemID := ids[idOffset]
if count != 1 || itemID <= 0 || itemID > math.MaxUint32 {
return nil
}
expand.Enabled, expand.ItemID = true, uint32(itemID)
}
idOffset += count
out = append(out, expand)
}
if idOffset != len(ids) {
return nil
}
valueOffset := 0
for index := range out {
if !out[index].Enabled {
if valueOffset >= len(values) {
return nil
}
// Disabled rarity rows retain one divisor placeholder.
valueOffset++
continue
}
if valueOffset+2 >= len(values) {
return nil
}
out[index].LevelDivisor, out[index].GreatChance, out[index].NormalChance = values[valueOffset], values[valueOffset+1], values[valueOffset+2]
valueOffset += 3
}
if valueOffset != len(values) {
return nil
}
return out
}
// currentDisjointLoadAvatarEmblemTables reads the per-grade avatar disjoint
// reward tables etc/avatardisjoint/emblemlistinfo_<grade>.etc until the first
// missing or unusable file. Missing tables are not an error here; only the
// avatar disjoint reward path requires them.
func currentDisjointLoadAvatarEmblemTables(source dnfpvf.Source) []currentAvatarEmblemGradeTable {
tables := make([]currentAvatarEmblemGradeTable, 0, 6)
for grade := 0; grade < 32; grade++ {
document, err := parseDungeonCardPVFDocument(source, "etc/avatardisjoint/emblemlistinfo_"+strconv.Itoa(grade)+".etc")
if err != nil {
break
}
table := currentDisjointParseAvatarEmblemTable(document)
if len(table.Pools) == 0 {
break
}
tables = append(tables, table)
}
return tables
}
func currentDisjointParseAvatarEmblemTable(document *dnfpvf.Document) currentAvatarEmblemGradeTable {
table := currentAvatarEmblemGradeTable{}
if document == nil {
return table
}
for _, section := range document.Sections {
if !strings.EqualFold(strings.TrimSpace(section.Name), "result info") {
continue
}
values := currentDisjointTokensInts(currentDisjointSectionTokens(document, section))
if len(values) < 5 || values[0] <= 0 {
continue
}
pool := currentAvatarEmblemPool{PickCount: int(values[0])}
for offset := 1; offset+3 < len(values); offset += 4 {
item, weight, count := values[offset], values[offset+1], values[offset+2]
if item <= 0 || item > math.MaxUint32 || weight <= 0 || weight > math.MaxUint32 || count <= 0 || count > math.MaxUint32 {
continue
}
pool.Rewards = append(pool.Rewards, currentAvatarEmblemReward{
ItemID: uint32(item),
Weight: uint32(weight),
Count: uint32(count),
Special: values[offset+3] != 0,
})
}
if len(pool.Rewards) > 0 {
table.Pools = append(table.Pools, pool)
}
}
return table
}
func currentDisjointParseAvatarByJob(document *dnfpvf.Document) map[string][]uint32 {
out := make(map[string][]uint32)
active := false
for _, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
switch name {
case "avatar disjoint info":
active = true
continue
case "/avatar disjoint info":
active = false
continue
}
if !active || strings.HasPrefix(name, "/") {
continue
}
values := make([]uint32, 0, 15)
for _, token := range currentDisjointSectionTokens(document, section) {
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint32 {
values = append(values, uint32(token.Int))
}
}
if len(values) >= 15 {
out[currentDisjointNormalizeJob(name)] = append([]uint32(nil), values[:15]...)
}
}
return out
}
func currentDisjointParseEmblemBoosters(values []int64) map[[2]int]uint32 {
out := make(map[[2]int]uint32)
for offset := 0; offset+1 < len(values); {
grade, maxCount := int(values[offset]), int(values[offset+1])
offset += 2
if grade <= 0 || maxCount < 2 || offset+maxCount-1 > len(values) {
return map[[2]int]uint32{}
}
for inputCount := 2; inputCount <= maxCount; inputCount++ {
value := values[offset]
offset++
if value > 0 && value <= math.MaxUint32 {
out[[2]int{grade, inputCount}] = uint32(value)
}
}
}
return out
}
// currentDisjointerRules is the slice of character/expertjob/disjointer.exj
// that ordinary (NPC) disassembly borrows: the machine-grade-0 [disjoint
// result] row and the [additional result] / [big win result] tables.
// *dnfexpertjob.DisjointerConfig implements it.
type currentDisjointerRules interface {
ResultRule(machineGradeColumn, rarity, state int) (dnfexpertjob.DisjointerResultRule, bool)
SelectAdditional(table, level int, rng dnfexpertjob.RandomFunc) (dnfexpertjob.DisjointerSelectionRule, bool)
SelectBigWin(table, level int, rng dnfexpertjob.RandomFunc) (dnfexpertjob.DisjointerSelectionRule, bool)
// ChronicleSetResult is [chronicle set disjoint result]; disassembly does
// not grant it, the upgrade destruction path does.
ChronicleSetResult() (itemID, count int64, ok bool)
}
// currentDisjointChronicleRarity is the PVF rarity index of 异界 (chronicle)
// equipment such as 裂魂之异次元 sets.
const currentDisjointChronicleRarity = 5
func currentEquipmentDisjointRewards(config currentDisjointPVFConfig, itemID uint32, document *dnfpvf.Document) ([]currentDisjointReward, error) {
return currentEquipmentDisjointRewardsForSource(config, nil, itemID, document, dnfrepo.ItemStack{})
}
// currentEquipmentDisjointRewardsForSource resolves ordinary disassembly:
// etc/disjoint.etc supplies the no-element cube, the rarity additional row,
// the soul expansion and the exact-item rows. Two families are not covered
// by that file in the live PVF and come from disjointer.exj instead:
//
// - 异界 (rarity 5) equipment: the etc row still names the legacy awakening
// material 混沌魔石碎片 at 21%, while the exj machine-grade-0 row for rarity
// 5 rolls table 5 — 虚空魔石碎片 (level 67+) / 混沌魔石 (below) — and its
// 9% big win rolls 虚空魔石. The 虚空魔石 item texts say exactly that
// ("可以通过分解勇者装备获得"), so 异界 equipment follows the exj tables.
// - equipment carrying an otherverse aura (红字, amplify type != 0) rolls
// the exj otherverse table on top: 浓缩的异界精髓 30% / 矛盾的结晶体 70%,
// one item on level% of the rolls (86JP
// MySqlInventoryStore.Disjoint → PlayerDisjointResultCalculator.AddOtherverseBonus).
//
// A nil rules value keeps the pure etc behaviour.
func currentEquipmentDisjointRewardsForSource(
config currentDisjointPVFConfig,
rules currentDisjointerRules,
itemID uint32,
document *dnfpvf.Document,
source dnfrepo.ItemStack,
) ([]currentDisjointReward, error) {
cubeReward, rarity, minimumLevel, err := currentEquipmentDisjointCubeReward(config, document)
if err != nil {
return nil, err
}
rewards := []currentDisjointReward{cubeReward}
chronicleHandled := false
if rules != nil && rarity == currentDisjointChronicleRarity {
granted, chronicleErr := currentDisjointAppendChronicleRewards(&rewards, rules, int(minimumLevel))
if chronicleErr != nil {
return nil, chronicleErr
}
chronicleHandled = granted
}
if !chronicleHandled && int(rarity) < len(config.Additional) && len(config.Additional[rarity]) > 0 {
count, granted, err := currentDisjointAdditionalCount(config, int(rarity), int(minimumLevel))
if err != nil {
return nil, err
}
if granted {
index, indexErr := currentDisjointRandomIndex(len(config.Additional[rarity]))
if indexErr != nil {
return nil, indexErr
}
currentDisjointMergeReward(&rewards, currentDisjointReward{ItemID: config.Additional[rarity][index], Count: count})
}
}
if reward, granted, err := currentDisjointExpandReward(config, int(rarity), int(minimumLevel)); err != nil {
return nil, err
} else if granted {
currentDisjointMergeReward(&rewards, reward)
}
if rules != nil && currentDisjointSourceAmplifyType(source) != 0 {
if err := currentDisjointAppendOtherverseReward(&rewards, rules, int(minimumLevel)); err != nil {
return nil, err
}
}
for _, reward := range config.IndependentByItem[itemID] {
currentDisjointMergeReward(&rewards, reward)
}
return rewards, nil
}
// currentDisjointAppendChronicleRewards rolls the disjointer.exj machine-grade-0
// row for 异界 equipment: the big-win chance selects between the additional
// and big-win tables, one weighted row whose level range covers the
// equipment is picked, and the count is level / divisor with a fractional
// roll. It reports false when the exj carries no such row so the caller can
// fall back to the etc additional row.
func currentDisjointAppendChronicleRewards(rewards *[]currentDisjointReward, rules currentDisjointerRules, level int) (bool, error) {
rule, found := rules.ResultRule(0, currentDisjointChronicleRarity, 0)
if !found {
return false, nil
}
bigWinRoll, err := currentDisjointRandomPercent()
if err != nil {
return false, err
}
bigWin := bigWinRoll < currentDisjointClampPercent(float64(rule.BigWinChancePercent))
var selected dnfexpertjob.DisjointerSelectionRule
var ok bool
if bigWin {
selected, ok = rules.SelectBigWin(rule.BigWinTable, level, currentDisjointRandomIntn)
} else {
selected, ok = rules.SelectAdditional(rule.AdditionalTable, level, currentDisjointRandomIntn)
}
if !ok || selected.ItemID <= 0 || selected.ItemID > math.MaxUint32 {
return true, nil
}
count := dnfexpertjob.LevelDivisorCount(level, selected.CountDivisor, currentDisjointRandomIntn)
if count <= 0 || count > math.MaxUint32 {
return true, nil
}
currentDisjointMergeReward(rewards, currentDisjointReward{ItemID: uint32(selected.ItemID), Count: uint32(count)})
return true, nil
}
// currentDisjointAppendOtherverseReward rolls the disjointer.exj otherverse
// table for equipment carrying an otherverse aura.
func currentDisjointAppendOtherverseReward(rewards *[]currentDisjointReward, rules currentDisjointerRules, level int) error {
selected, ok := rules.SelectAdditional(dnfexpertjob.DisjointerOtherverseTable, level, currentDisjointRandomIntn)
if !ok || selected.ItemID <= 0 || selected.ItemID > math.MaxUint32 {
return nil
}
count := dnfexpertjob.LevelDivisorCount(level, selected.CountDivisor, currentDisjointRandomIntn)
if count <= 0 || count > math.MaxUint32 {
return nil
}
currentDisjointMergeReward(rewards, currentDisjointReward{ItemID: uint32(selected.ItemID), Count: uint32(count)})
return nil
}
// currentDisjointSourceAmplifyType reads the equipment instance's amplify type
// byte (raw entry offset 0x13; nonzero means the item carries an otherverse
// aura, purified or not) from the persisted extras or the raw entry.
func currentDisjointSourceAmplifyType(source dnfrepo.ItemStack) byte {
if value := sceneInventoryExtraByte(source.Extra, "amplify_type", "amplification_type", "amplify_attr", "amplification_attr", "byte_13", "value_13", "value_c"); value != 0 {
return value
}
if len(source.RawEntry) > 0x13 {
return source.RawEntry[0x13]
}
return 0
}
// currentDisjointRandomIntn adapts the disjoint RNG to the expert-job
// RandomFunc contract.
func currentDisjointRandomIntn(limit int) int {
if limit <= 1 {
return 0
}
value, err := currentDisjointRandomUint64(uint64(limit))
if err != nil {
return 0
}
return int(value)
}
// currentEquipmentDisjointCubeReward isolates the PVF-backed no-element cube
// baseline without normal disassembly's rarity-specific additional rewards.
func currentEquipmentDisjointCubeReward(config currentDisjointPVFConfig, document *dnfpvf.Document) (currentDisjointReward, int64, int64, error) {
if document == nil || config.CubeItemID == 0 || config.CubeBase <= 0 {
return currentDisjointReward{}, 0, 0, errCurrentDisjointRewardInvalid
}
rarity, found := document.Int("rarity")
if !found || rarity < 0 || rarity >= int64(len(config.CubeMultipliers)) {
return currentDisjointReward{}, 0, 0, errCurrentDisjointRewardInvalid
}
minimumLevel, levelFound := document.Int("minimum level")
if !levelFound || minimumLevel < 1 {
minimumLevel = 1
}
value, valueFound := document.Int("value")
if !valueFound || value <= 0 {
value, valueFound = document.Int("price")
}
if !valueFound || value <= 0 {
if currentDisjointDocumentIsTitle(document) {
// Current title documents intentionally have no sale value. The
// compatibility extension grants the minimum baseline cube and
// still applies the PVF rarity-specific additional rows below.
return currentDisjointReward{ItemID: config.CubeItemID, Count: 1}, rarity, minimumLevel, nil
}
return currentDisjointReward{}, 0, 0, errCurrentDisjointRewardInvalid
}
if value > math.MaxInt64/config.EquipmentSellRate {
return currentDisjointReward{}, 0, 0, errCurrentDisjointRewardInvalid
}
sellGold := value * config.EquipmentSellRate / 1000
if sellGold < 1 {
sellGold = 1
}
cubeCount := uint32(math.Max(1, math.Floor(float64(sellGold)*config.CubeMultipliers[rarity]/config.CubeBase)))
return currentDisjointReward{ItemID: config.CubeItemID, Count: cubeCount}, rarity, minimumLevel, nil
}
func currentDisjointExpandReward(config currentDisjointPVFConfig, rarity, level int) (currentDisjointReward, bool, error) {
if rarity < 0 || rarity >= len(config.Expands) {
return currentDisjointReward{}, false, nil
}
expand := config.Expands[rarity]
if !expand.Enabled || expand.ItemID == 0 {
return currentDisjointReward{}, false, nil
}
roll, err := currentDisjointRandomPercent()
if err != nil {
return currentDisjointReward{}, false, err
}
if roll >= currentDisjointClampPercent(expand.NormalChance) {
return currentDisjointReward{}, false, nil
}
count, err := currentDisjointLevelDivisorCount(level, expand.LevelDivisor)
if err != nil {
return currentDisjointReward{}, false, err
}
greatRoll, err := currentDisjointRandomPercent()
if err != nil {
return currentDisjointReward{}, false, err
}
if greatRoll < currentDisjointClampPercent(expand.GreatChance) {
if count == math.MaxUint32 {
return currentDisjointReward{}, false, errCurrentDisjointRewardInvalid
}
count++
}
return currentDisjointReward{ItemID: expand.ItemID, Count: count}, true, nil
}
func currentDisjointAdditionalCount(config currentDisjointPVFConfig, rarity, level int) (uint32, bool, error) {
if rarity < 0 || rarity >= len(config.AdditionalConsts) {
return 0, false, nil
}
consts := config.AdditionalConsts[rarity]
greatChance := currentDisjointClampPercent(consts.GreatChance)
normalChance := currentDisjointClampPercent(consts.NormalChance)
roll, err := currentDisjointRandomPercent()
if err != nil {
return 0, false, err
}
divisor := float64(0)
switch {
case roll < greatChance:
divisor = consts.GreatDivisor
case roll < math.Min(100, greatChance+normalChance):
divisor = consts.NormalDivisor
default:
return 0, false, nil
}
if divisor <= 0 {
return 0, false, nil
}
count, err := currentDisjointLevelDivisorCount(level, divisor)
if err != nil {
return 0, false, err
}
return count, true, nil
}
// currentAvatarDisjointRewards resolves the avatar's PVF [grade] into the
// matching etc/avatardisjoint grade table and rolls every pool's weighted
// picks (86JP AvatarDisjointConfigProvider.Calculate), merging duplicate
// items. The rewards are real emblem items, not the old fixed booster boxes.
func currentAvatarDisjointRewards(config currentDisjointPVFConfig, document *dnfpvf.Document) ([]currentDisjointReward, error) {
if document == nil {
return nil, errCurrentDisjointRewardInvalid
}
grade, gradeFound := document.Int("grade")
equipmentType, typeFound := document.Text("equipment type")
rule, supported := currentEquipmentPlacementRuleForPVFType(equipmentType)
if !gradeFound || !typeFound || !supported || rule.class != currentEquipmentPlacementClassAvatar {
return nil, errCurrentDisjointRewardInvalid
}
if grade < 0 || grade >= int64(len(config.AvatarEmblemTables)) {
return nil, errCurrentDisjointRewardInvalid
}
rewards := make([]currentDisjointReward, 0, 4)
for _, pool := range config.AvatarEmblemTables[int(grade)].Pools {
for pick := 0; pick < pool.PickCount; pick++ {
reward, err := currentDisjointRollAvatarEmblem(pool.Rewards)
if err != nil {
return nil, err
}
merged := false
for index := range rewards {
if rewards[index].ItemID == reward.ItemID {
rewards[index].Count += reward.Count
merged = true
break
}
}
if !merged {
rewards = append(rewards, reward)
}
}
}
if len(rewards) == 0 {
return nil, errCurrentDisjointRewardInvalid
}
return rewards, nil
}
func currentDisjointRollAvatarEmblem(candidates []currentAvatarEmblemReward) (currentDisjointReward, error) {
var total uint64
for _, candidate := range candidates {
total += uint64(candidate.Weight)
}
if total == 0 {
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
roll, err := currentDisjointRandomUint64(total)
if err != nil {
return currentDisjointReward{}, err
}
for _, candidate := range candidates {
if roll < uint64(candidate.Weight) {
return currentDisjointReward{ItemID: candidate.ItemID, Count: candidate.Count}, nil
}
roll -= uint64(candidate.Weight)
}
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
func currentDisjointDocumentIsAvatar(document *dnfpvf.Document) bool {
if document == nil {
return false
}
equipmentType, found := document.Text("equipment type")
if !found {
return false
}
rule, supported := currentEquipmentPlacementRuleForPVFType(equipmentType)
return supported && rule.class == currentEquipmentPlacementClassAvatar
}
func currentDisjointDocumentIsTitle(document *dnfpvf.Document) bool {
if document == nil {
return false
}
equipmentType, found := document.Text("equipment type")
return found && normalizeEquipmentPlacementPVFType(equipmentType) == "[title name]"
}
func currentRollEmblemBoosterReward(catalog *pvfDungeonDropCatalog, boosterID uint32) (currentDisjointReward, error) {
definition, err := catalog.ResolveItem(boosterID)
if err != nil || definition.Kind != dungeonDropItemStackable {
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
document, err := parseDungeonCardPVFDocument(catalog.source, definition.PVFPath)
if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(documentText(document, "stackable type"))), "[booster]") {
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
active := false
for _, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
switch name {
case "booster info":
active = true
continue
case "/booster info":
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
if !active || name != "stackable" {
continue
}
return currentRollEmblemBoosterPool(currentDisjointTokensInts(currentDisjointSectionTokens(document, section)))
}
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
func currentRollEmblemBoosterPool(values []int64) (currentDisjointReward, error) {
start := 0
if len(values) >= 4 && (len(values)-1)%3 == 0 {
start = 1 // draw-count; op256 supports one popup result only.
}
if (len(values)-start)%3 != 0 || len(values) == start {
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
type candidate struct{ itemID, weight, count uint32 }
candidates := make([]candidate, 0, (len(values)-start)/3)
var total uint64
for offset := start; offset+2 < len(values); offset += 3 {
if values[offset] <= 0 || values[offset] > math.MaxUint32 || values[offset+1] <= 0 || values[offset+1] > math.MaxUint32 || values[offset+2] <= 0 || values[offset+2] > math.MaxUint32 {
continue
}
entry := candidate{uint32(values[offset]), uint32(values[offset+1]), uint32(values[offset+2])}
total += uint64(entry.weight)
candidates = append(candidates, entry)
}
if total == 0 || len(candidates) == 0 {
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
roll, err := currentDisjointRandomUint64(total)
if err != nil {
return currentDisjointReward{}, err
}
for _, candidate := range candidates {
if roll < uint64(candidate.weight) {
return currentDisjointReward{ItemID: candidate.itemID, Count: candidate.count}, nil
}
roll -= uint64(candidate.weight)
}
return currentDisjointReward{}, errCurrentDisjointRewardInvalid
}
func documentText(document *dnfpvf.Document, name string) string {
if document == nil {
return ""
}
value, _ := document.Text(name)
return value
}
func currentDisjointTokensInts(tokens []dnfpvf.Token) []int64 {
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
return values
}
func currentDisjointLevelDivisorCount(level int, divisor float64) (uint32, error) {
if divisor <= 0 {
return 1, nil
}
value := float64(max(1, level)) / divisor
count := math.Floor(value)
roll, err := currentDisjointRandomPercent()
if err != nil {
return 0, err
}
if roll/100 < value-count {
count++
}
if count > math.MaxUint32 {
return 0, errCurrentDisjointRewardInvalid
}
// 86JP DisjointResultCalculator.CalculateLevelDivisorCount clamps the
// rolled count to at least 1; a low-level source still yields one
// additional/expand item instead of failing the whole disjoint.
if count < 1 {
count = 1
}
return uint32(count), nil
}
func currentDisjointMergeReward(rewards *[]currentDisjointReward, reward currentDisjointReward) {
if rewards == nil || reward.ItemID == 0 || reward.Count == 0 {
return
}
for index := range *rewards {
if (*rewards)[index].ItemID == reward.ItemID {
(*rewards)[index].Count += reward.Count
return
}
}
*rewards = append(*rewards, reward)
}
func currentDisjointNormalizeJob(value string) string {
value = strings.ToLower(strings.TrimSpace(strings.Trim(value, "`")))
return strings.TrimSpace(strings.Trim(value, "[]"))
}
func currentDisjointClampPercent(value float64) float64 {
return math.Max(0, math.Min(100, value))
}
func currentDisjointRandomIndex(limit int) (int, error) {
if limit <= 0 {
return 0, errCurrentDisjointRewardInvalid
}
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(limit)))
if err != nil {
return 0, err
}
return int(value.Int64()), nil
}
func currentDisjointRandomUint64(limit uint64) (uint64, error) {
if limit == 0 || limit > math.MaxInt64 {
return 0, errCurrentDisjointRewardInvalid
}
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(limit)))
if err != nil {
return 0, err
}
return uint64(value.Int64()), nil
}
func currentDisjointRandomPercent() (float64, error) {
value, err := currentDisjointRandomUint64(1_000_000)
if err != nil {
return 0, err
}
return float64(value) / 10_000, nil
}