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

454 lines
16 KiB
Go

package drop
import (
"errors"
"fmt"
"math"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const ClearRewardRulePath = "Etc/ItemDropInfo_ClearReward.etc"
const (
// ClearRewardItemRateDenominator is the probability scale used by the
// ordinary dungeon-clear equipment roll in the current PVF.
ClearRewardItemRateDenominator = uint32(10_000)
clearRewardBonusDenominator = float64(1_000)
)
var (
ErrClearRewardRuleSourceRequired = errors.New("dnf clear-reward rule PVF source is required")
ErrClearRewardRuleSectionMissing = errors.New("dnf clear-reward rule PVF section is missing")
ErrClearRewardRuleSectionShape = errors.New("dnf clear-reward rule PVF section shape is invalid")
ErrClearRewardRuleLookupMissing = errors.New("dnf clear-reward rule lookup is missing")
)
// ClearRewardItemReference preserves one exact three-column
// [item drop ref table] row. The two values remain opaque until the current
// client confirms their item-level interpretation.
type ClearRewardItemReference struct {
LookupKey int64
ValueA int64
ValueB int64
}
// ClearRewardRuleSnapshot reports only loaded PVF source coverage.
type ClearRewardRuleSnapshot struct {
MapRateRows int
DifficultyBonusRows int
RarityThresholdRows int
PartyBonusRows int
GoldDifficultyRows int
GoldCardCostRows int
ItemReferenceRows int
}
// ClearRewardRawTables is a value-copy of fixed-size clear-reward source
// tables.
type ClearRewardRawTables struct {
ItemRateByMapCount map[int64]int64
DifficultyDropBonus [5]int64
RarityDecision [7]int64
PartyMemberDropBonus [4]float64
GoldDifficultyDropBonus [5]float64
GoldCardCostByLevel map[int64]int64
}
// ClearRewardSourcePlan is a pure lookup. It neither rolls a card reward nor
// charges gold; callers can use it to correlate a real-client packet trace
// with the exact rows from the runtime PVF.
type ClearRewardSourcePlan struct {
Level int64
DifficultyIndex int
PartyMembers int
VisitedRoomCount int
ItemRate int64
DifficultyBonus int64
PartyBonus float64
GoldDifficultyRate float64
GoldCardCost int64
ItemReference ClearRewardItemReference
ItemRateFound bool
DifficultyFound bool
PartyBonusFound bool
GoldDifficultyFound bool
GoldCardCostFound bool
ItemReferenceFound bool
}
// ClearRewardRule is the resolved ordinary end-of-dungeon card rule. The
// equipment rate and rarity thresholds are read from the active PVF; it does
// not select or grant an item.
type ClearRewardRule struct {
EquipmentRate uint32
GradeDown int64
GradeUp int64
RarityThresholds [7]uint32
RarityScale uint32
PaidCardCost uint32
GoldDifficultyRate float64
MapCount int
}
// ClearRewardRules is a read-only typed view of
// Etc/ItemDropInfo_ClearReward.etc. The data can be safely inspected by
// card-reward and diagnostics code without owning card selection or rewards.
type ClearRewardRules struct {
itemRateByMapCount map[int64]int64
difficultyDropBonus [5]int64
rarityDecision [7]int64
partyMemberDropBonus [4]float64
goldDifficultyDropBonus [5]float64
goldCardCostByLevel map[int64]int64
itemReferences map[int64]ClearRewardItemReference
}
func LoadClearRewardRules(source dnfpvf.Source) (*ClearRewardRules, error) {
if source == nil {
return nil, ErrClearRewardRuleSourceRequired
}
text, err := source.ReadText(ClearRewardRulePath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", ClearRewardRulePath, err)
}
document, err := dnfpvf.Parse(ClearRewardRulePath, text)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", ClearRewardRulePath, err)
}
rules := &ClearRewardRules{
itemRateByMapCount: make(map[int64]int64, 5),
goldCardCostByLevel: make(map[int64]int64),
itemReferences: make(map[int64]ClearRewardItemReference),
}
if err := loadClearRewardMapRates(document, rules); err != nil {
return nil, err
}
if err := fillClearRewardIntArray(document, "dungeon difficulty drop bonusrate", rules.difficultyDropBonus[:]); err != nil {
return nil, err
}
for index, value := range rules.difficultyDropBonus {
if value < 0 {
return nil, fmt.Errorf("%w: section=dungeon difficulty drop bonusrate row=%d value=%d", ErrClearRewardRuleSectionShape, index, value)
}
}
if err := fillClearRewardIntArray(document, "basis of rarity dicision", rules.rarityDecision[:]); err != nil {
return nil, err
}
for index, value := range rules.rarityDecision {
if value < 0 || index > 0 && value < rules.rarityDecision[index-1] {
return nil, fmt.Errorf("%w: section=basis of rarity dicision row=%d value=%d", ErrClearRewardRuleSectionShape, index, value)
}
}
if rules.rarityDecision[len(rules.rarityDecision)-1] == 0 {
return nil, fmt.Errorf("%w: section=basis of rarity dicision final_threshold=0", ErrClearRewardRuleSectionShape)
}
if err := fillClearRewardNumberArray(document, "party member drop bonusrate", rules.partyMemberDropBonus[:]); err != nil {
return nil, err
}
if err := fillClearRewardNumberArray(document, "dungeon difficulty gold drop bonusrate", rules.goldDifficultyDropBonus[:]); err != nil {
return nil, err
}
if err := loadClearRewardGoldCardCosts(document, rules); err != nil {
return nil, err
}
if err := loadClearRewardItemReferences(document, rules); err != nil {
return nil, err
}
return rules, nil
}
func loadClearRewardMapRates(document *dnfpvf.Document, rules *ClearRewardRules) error {
values, err := clearRewardIntegers(document, "reward item rate per map max count")
if err != nil {
return err
}
if len(values) != 10 {
return fmt.Errorf("%w: section=reward item rate per map max count values=%d row_width=2", ErrClearRewardRuleSectionShape, len(values))
}
for offset := 0; offset < len(values); offset += 2 {
mapCount, rate := values[offset], values[offset+1]
wantMapCount := int64(offset/2 + 1)
if mapCount != wantMapCount || rate < 0 {
return fmt.Errorf("%w: section=reward item rate per map max count row=%d count=%d rate=%d", ErrClearRewardRuleSectionShape, offset/2, mapCount, rate)
}
rules.itemRateByMapCount[mapCount] = rate
}
return nil
}
func loadClearRewardGoldCardCosts(document *dnfpvf.Document, rules *ClearRewardRules) error {
values, err := clearRewardIntegers(document, "gold card cost table")
if err != nil {
return err
}
if len(values) == 0 || len(values)%2 != 0 {
return fmt.Errorf("%w: section=gold card cost table values=%d row_width=2", ErrClearRewardRuleSectionShape, len(values))
}
var previous int64
for offset := 0; offset < len(values); offset += 2 {
level, cost := values[offset], values[offset+1]
if level <= 0 || cost <= 0 || offset != 0 && level <= previous {
return fmt.Errorf("%w: section=gold card cost table row=%d level=%d cost=%d", ErrClearRewardRuleSectionShape, offset/2, level, cost)
}
rules.goldCardCostByLevel[level] = cost
previous = level
}
return nil
}
func loadClearRewardItemReferences(document *dnfpvf.Document, rules *ClearRewardRules) error {
values, err := clearRewardIntegers(document, "item drop ref table")
if err != nil {
return err
}
if len(values) == 0 || len(values)%3 != 0 {
return fmt.Errorf("%w: section=item drop ref table values=%d row_width=3", ErrClearRewardRuleSectionShape, len(values))
}
var previous int64
for offset := 0; offset < len(values); offset += 3 {
row := ClearRewardItemReference{LookupKey: values[offset], ValueA: values[offset+1], ValueB: values[offset+2]}
if row.LookupKey <= 0 || row.ValueA < 0 || row.ValueB < 0 || offset != 0 && row.LookupKey <= previous {
return fmt.Errorf("%w: section=item drop ref table row=%d value=%+v", ErrClearRewardRuleSectionShape, offset/3, row)
}
rules.itemReferences[row.LookupKey] = row
previous = row.LookupKey
}
return nil
}
func clearRewardIntegers(document *dnfpvf.Document, section string) ([]int64, error) {
tokens, found := document.Section(section)
if !found {
return nil, fmt.Errorf("%w: section=%s", ErrClearRewardRuleSectionMissing, section)
}
if len(tokens) == 0 {
return nil, fmt.Errorf("%w: section=%s empty", ErrClearRewardRuleSectionShape, section)
}
values := make([]int64, 0, len(tokens))
for index, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrClearRewardRuleSectionShape, section, index, token.Kind)
}
values = append(values, token.Int)
}
return values, nil
}
func clearRewardNumbers(document *dnfpvf.Document, section string) ([]float64, error) {
tokens, found := document.Section(section)
if !found {
return nil, fmt.Errorf("%w: section=%s", ErrClearRewardRuleSectionMissing, section)
}
if len(tokens) == 0 {
return nil, fmt.Errorf("%w: section=%s empty", ErrClearRewardRuleSectionShape, section)
}
values := make([]float64, 0, len(tokens))
for index, token := range tokens {
var value float64
switch token.Kind {
case dnfpvf.TokenInt:
value = float64(token.Int)
case dnfpvf.TokenFloat:
value = token.Float
default:
return nil, fmt.Errorf("%w: section=%s token=%d kind=%s", ErrClearRewardRuleSectionShape, section, index, token.Kind)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 {
return nil, fmt.Errorf("%w: section=%s token=%d value=%v", ErrClearRewardRuleSectionShape, section, index, value)
}
values = append(values, value)
}
return values, nil
}
func fillClearRewardIntArray(document *dnfpvf.Document, section string, destination []int64) error {
values, err := clearRewardIntegers(document, section)
if err != nil {
return err
}
if len(values) != len(destination) {
return fmt.Errorf("%w: section=%s values=%d want=%d", ErrClearRewardRuleSectionShape, section, len(values), len(destination))
}
copy(destination, values)
return nil
}
func fillClearRewardNumberArray(document *dnfpvf.Document, section string, destination []float64) error {
values, err := clearRewardNumbers(document, section)
if err != nil {
return err
}
if len(values) != len(destination) {
return fmt.Errorf("%w: section=%s values=%d want=%d", ErrClearRewardRuleSectionShape, section, len(values), len(destination))
}
copy(destination, values)
return nil
}
func (rules *ClearRewardRules) ItemReference(level int64) (ClearRewardItemReference, bool) {
if rules == nil {
return ClearRewardItemReference{}, false
}
reference, found := rules.itemReferences[level]
return reference, found
}
// PartyMemberRate is the PVF [party member drop bonusrate] multiplier for a
// party size, clamped to the four rows the table defines. The clear-card gold
// formula applies it alongside the difficulty rate; a size outside the table
// contributes no bonus rather than zeroing the reward.
func (rules *ClearRewardRules) PartyMemberRate(partyMembers int) float64 {
if rules == nil {
return 1
}
if partyMembers < 1 {
partyMembers = 1
} else if partyMembers > len(rules.partyMemberDropBonus) {
partyMembers = len(rules.partyMemberDropBonus)
}
rate := rules.partyMemberDropBonus[partyMembers-1]
if rate <= 0 {
return 1
}
return rate
}
func (rules *ClearRewardRules) PlanSource(level int64, difficultyIndex, partyMembers, visitedRoomCount int) ClearRewardSourcePlan {
plan := ClearRewardSourcePlan{
Level: level,
DifficultyIndex: difficultyIndex,
PartyMembers: partyMembers,
VisitedRoomCount: visitedRoomCount,
}
if rules == nil {
return plan
}
plan.ItemRate, plan.ItemRateFound = rules.itemRateByMapCount[int64(visitedRoomCount)]
if difficultyIndex >= 0 && difficultyIndex < len(rules.difficultyDropBonus) {
plan.DifficultyBonus = rules.difficultyDropBonus[difficultyIndex]
plan.DifficultyFound = true
plan.GoldDifficultyRate = rules.goldDifficultyDropBonus[difficultyIndex]
plan.GoldDifficultyFound = true
}
if partyMembers >= 1 && partyMembers <= len(rules.partyMemberDropBonus) {
plan.PartyBonus = rules.partyMemberDropBonus[partyMembers-1]
plan.PartyBonusFound = true
}
plan.GoldCardCost, plan.GoldCardCostFound = rules.goldCardCostByLevel[level]
plan.ItemReference, plan.ItemReferenceFound = rules.ItemReference(level)
return plan
}
// Resolve applies the formula used by the current server/client clear-card
// flow: room rate multiplied by the additive difficulty bonus (1/1000 scale)
// and the party-size multiplier, capped to the PVF's 1/10000 probability
// denominator. Normal difficulty has a zero additive bonus, not a zero
// multiplier.
func (rules *ClearRewardRules) Resolve(level int64, difficultyIndex, partyMembers, visitedRoomCount int) (ClearRewardRule, error) {
if rules == nil || level <= 0 {
return ClearRewardRule{}, ErrClearRewardRuleSourceRequired
}
if difficultyIndex < 0 || difficultyIndex >= len(rules.difficultyDropBonus) {
difficultyIndex = 0
}
if partyMembers < 1 {
partyMembers = 1
} else if partyMembers > len(rules.partyMemberDropBonus) {
partyMembers = len(rules.partyMemberDropBonus)
}
if visitedRoomCount < 1 {
visitedRoomCount = 1
} else if visitedRoomCount > len(rules.itemRateByMapCount) {
visitedRoomCount = len(rules.itemRateByMapCount)
}
plan := rules.PlanSource(level, difficultyIndex, partyMembers, visitedRoomCount)
if !plan.ItemRateFound || !plan.DifficultyFound || !plan.PartyBonusFound ||
!plan.GoldDifficultyFound || !plan.GoldCardCostFound || !plan.ItemReferenceFound {
return ClearRewardRule{}, fmt.Errorf(
"%w: level=%d difficulty=%d party=%d rooms=%d coverage=%+v",
ErrClearRewardRuleLookupMissing, level, difficultyIndex, partyMembers, visitedRoomCount, plan,
)
}
if plan.ItemRate < 0 || plan.DifficultyBonus < 0 || plan.PartyBonus <= 0 ||
math.IsNaN(plan.PartyBonus) || math.IsInf(plan.PartyBonus, 0) ||
plan.GoldCardCost <= 0 || plan.GoldCardCost > math.MaxUint32 ||
plan.ItemReference.ValueA < 0 || plan.ItemReference.ValueB < 0 {
return ClearRewardRule{}, fmt.Errorf("%w: resolved source=%+v", ErrClearRewardRuleSectionShape, plan)
}
raw := rules.RawTables()
var rarityThresholds [7]uint32
for index, threshold := range raw.RarityDecision {
if threshold < 0 || threshold > math.MaxUint32 {
return ClearRewardRule{}, fmt.Errorf(
"%w: section=basis of rarity dicision row=%d value=%d",
ErrClearRewardRuleSectionShape, index, threshold,
)
}
rarityThresholds[index] = uint32(threshold)
}
rarityScale := rarityThresholds[len(rarityThresholds)-1]
if rarityScale == 0 {
return ClearRewardRule{}, fmt.Errorf("%w: section=basis of rarity dicision final_threshold=0", ErrClearRewardRuleSectionShape)
}
rate := math.Floor(float64(plan.ItemRate) *
(clearRewardBonusDenominator + float64(plan.DifficultyBonus)) /
clearRewardBonusDenominator * plan.PartyBonus)
if rate < 0 {
rate = 0
} else if rate > float64(ClearRewardItemRateDenominator) {
rate = float64(ClearRewardItemRateDenominator)
}
return ClearRewardRule{
EquipmentRate: uint32(rate),
GradeDown: plan.ItemReference.ValueA,
GradeUp: plan.ItemReference.ValueB,
RarityThresholds: rarityThresholds,
RarityScale: rarityScale,
PaidCardCost: uint32(plan.GoldCardCost),
GoldDifficultyRate: plan.GoldDifficultyRate,
MapCount: visitedRoomCount,
}, nil
}
func (rules *ClearRewardRules) Snapshot() ClearRewardRuleSnapshot {
if rules == nil {
return ClearRewardRuleSnapshot{}
}
return ClearRewardRuleSnapshot{
MapRateRows: len(rules.itemRateByMapCount),
DifficultyBonusRows: len(rules.difficultyDropBonus),
RarityThresholdRows: len(rules.rarityDecision),
PartyBonusRows: len(rules.partyMemberDropBonus),
GoldDifficultyRows: len(rules.goldDifficultyDropBonus),
GoldCardCostRows: len(rules.goldCardCostByLevel),
ItemReferenceRows: len(rules.itemReferences),
}
}
func (rules *ClearRewardRules) RawTables() ClearRewardRawTables {
if rules == nil {
return ClearRewardRawTables{}
}
return ClearRewardRawTables{
ItemRateByMapCount: cloneClearRewardIntMap(rules.itemRateByMapCount),
DifficultyDropBonus: rules.difficultyDropBonus,
RarityDecision: rules.rarityDecision,
PartyMemberDropBonus: rules.partyMemberDropBonus,
GoldDifficultyDropBonus: rules.goldDifficultyDropBonus,
GoldCardCostByLevel: cloneClearRewardIntMap(rules.goldCardCostByLevel),
}
}
func cloneClearRewardIntMap(source map[int64]int64) map[int64]int64 {
if len(source) == 0 {
return nil
}
clone := make(map[int64]int64, len(source))
for key, value := range source {
clone[key] = value
}
return clone
}