服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
490 lines
18 KiB
Go
490 lines
18 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
const (
|
|
currentDungeonIndependentDropPath = "Etc/Independent_Drop.etc"
|
|
currentDungeonWorldDropPath = "Etc/WorldDrop.etc"
|
|
currentDungeonIndependentDropDenominator = 1000000
|
|
currentDungeonWorldDropDenominator = 100000
|
|
currentDungeonWorldDropMaxLevel = 199
|
|
)
|
|
|
|
type currentDungeonSpecialDropWeightedItem struct {
|
|
ItemID uint32
|
|
Weight uint32
|
|
}
|
|
|
|
// Independent_Drop.etc record types (first PVF column). The second column is
|
|
// an actor code whose namespace depends on this type: type 0 rows are keyed by
|
|
// monster code, type 1 rows by APC (AI character) code. 86JPPP keys its
|
|
// catalog by (DefinitionType, ActorCode) and S4A21 skips type 1 outright; both
|
|
// agree that a type-1 row never applies to a monster kill. The numeric spaces
|
|
// overlap (APC 420/421/430 vs. monsters Spider/SpiderPoison/YoungSpider), so
|
|
// merging them by code made 蜘蛛洞穴 spiders drop the Hell-party invitation,
|
|
// challenge letter and 史诗灵魂碎片 that belong to APC 420/421.
|
|
const (
|
|
currentDungeonIndependentDropTypeMonster = 0
|
|
currentDungeonIndependentDropTypeAPC = 1
|
|
)
|
|
|
|
type currentDungeonIndependentDropEntry struct {
|
|
// EntryType is the record's first PVF column. Type 0 caps the boosted
|
|
// probability at the denominator and rolls once per attempt; type 1 keeps
|
|
// probabilities above 100%: `divmod(prob, denominator)` grants the whole
|
|
// multiplier per attempt as guaranteed drops and rolls only the residual
|
|
// (usdof_v54 game/drop/independent_drop.py `generate`, proven against
|
|
// _disasm/game/drop/independent_drop.dis.txt lines 250-269). Type 1 rows
|
|
// live in IndependentByAPC and are never consulted for a monster kill.
|
|
EntryType int
|
|
ItemID uint32
|
|
Probability [5]int
|
|
Attempts [5]int
|
|
LevelMin int
|
|
LevelMax int
|
|
Difficulty int
|
|
Items []currentDungeonSpecialDropWeightedItem
|
|
TotalWeight uint32
|
|
}
|
|
|
|
type currentDungeonWorldDropLevel struct {
|
|
Items []currentDungeonSpecialDropWeightedItem
|
|
TotalWeight uint32
|
|
}
|
|
|
|
// currentDungeonIndependentDropTables is the parsed Independent_Drop.etc split
|
|
// by record type. The two maps share the int64 code space but not its meaning.
|
|
type currentDungeonIndependentDropTables struct {
|
|
ByMonster map[int64][]currentDungeonIndependentDropEntry
|
|
ByAPC map[int64][]currentDungeonIndependentDropEntry
|
|
}
|
|
|
|
type currentDungeonSpecialDropCatalog struct {
|
|
// IndependentByMonster holds type-0 rows keyed by monster code. This is
|
|
// the only table a monster death consults.
|
|
IndependentByMonster map[int64][]currentDungeonIndependentDropEntry
|
|
// IndependentByAPC holds type-1 rows keyed by APC code. No APC death
|
|
// currently rolls independent drops; the rows are kept parsed so that
|
|
// path can be added without re-reading the PVF semantics.
|
|
IndependentByAPC map[int64][]currentDungeonIndependentDropEntry
|
|
WorldByLevel map[int]currentDungeonWorldDropLevel
|
|
SkippedListFlag2 int
|
|
}
|
|
|
|
type currentDungeonSpecialDropCacheEntry struct {
|
|
catalog *currentDungeonSpecialDropCatalog
|
|
err error
|
|
}
|
|
|
|
var currentDungeonSpecialDropsByMonsterCatalog sync.Map
|
|
|
|
func currentDungeonSpecialDrops(catalog *pvfDungeonMonsterCatalog) (*currentDungeonSpecialDropCatalog, error) {
|
|
if catalog == nil || catalog.source == nil {
|
|
return nil, errDungeonDropSourceRequired
|
|
}
|
|
if cached, found := currentDungeonSpecialDropsByMonsterCatalog.Load(catalog); found {
|
|
entry := cached.(currentDungeonSpecialDropCacheEntry)
|
|
return entry.catalog, entry.err
|
|
}
|
|
loaded, err := loadCurrentDungeonSpecialDrops(catalog.source)
|
|
entry := currentDungeonSpecialDropCacheEntry{catalog: loaded, err: err}
|
|
actual, _ := currentDungeonSpecialDropsByMonsterCatalog.LoadOrStore(catalog, entry)
|
|
entry = actual.(currentDungeonSpecialDropCacheEntry)
|
|
return entry.catalog, entry.err
|
|
}
|
|
|
|
func loadCurrentDungeonSpecialDrops(source dnfpvf.Source) (*currentDungeonSpecialDropCatalog, error) {
|
|
if source == nil {
|
|
return nil, errDungeonDropSourceRequired
|
|
}
|
|
catalog := ¤tDungeonSpecialDropCatalog{
|
|
IndependentByMonster: make(map[int64][]currentDungeonIndependentDropEntry),
|
|
IndependentByAPC: make(map[int64][]currentDungeonIndependentDropEntry),
|
|
WorldByLevel: make(map[int]currentDungeonWorldDropLevel),
|
|
}
|
|
if text, err := source.ReadText(currentDungeonIndependentDropPath); err == nil && strings.TrimSpace(text) != "" {
|
|
tables, skipped, parseErr := parseCurrentDungeonIndependentDrops(text)
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
catalog.IndependentByMonster = tables.ByMonster
|
|
catalog.IndependentByAPC = tables.ByAPC
|
|
catalog.SkippedListFlag2 = skipped
|
|
}
|
|
if text, err := source.ReadText(currentDungeonWorldDropPath); err == nil && strings.TrimSpace(text) != "" {
|
|
levels, parseErr := parseCurrentDungeonWorldDrops(text)
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
catalog.WorldByLevel = levels
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func parseCurrentDungeonIndependentDrops(text string) (currentDungeonIndependentDropTables, int, error) {
|
|
result := currentDungeonIndependentDropTables{
|
|
ByMonster: make(map[int64][]currentDungeonIndependentDropEntry),
|
|
ByAPC: make(map[int64][]currentDungeonIndependentDropEntry),
|
|
}
|
|
document, err := dnfpvf.Parse(currentDungeonIndependentDropPath, text)
|
|
if err != nil {
|
|
return result, 0, err
|
|
}
|
|
tokens := document.Tokens
|
|
index := currentDungeonDropSectionToken(tokens, "independent drop")
|
|
if index < 0 {
|
|
return result, 0, fmt.Errorf("%s: [independent drop] section missing", currentDungeonIndependentDropPath)
|
|
}
|
|
index++
|
|
skippedListFlag2 := 0
|
|
for index < len(tokens) {
|
|
if currentDungeonDropTokenSection(tokens[index], "/independent drop") {
|
|
break
|
|
}
|
|
if tokens[index].Kind == dnfpvf.TokenSection {
|
|
return result, 0, fmt.Errorf("%s: unexpected section %q at line %d", currentDungeonIndependentDropPath, tokens[index].Value, tokens[index].Line)
|
|
}
|
|
if index+17 > len(tokens) {
|
|
return result, 0, fmt.Errorf("%s: truncated independent-drop record at token %d", currentDungeonIndependentDropPath, index)
|
|
}
|
|
var values [17]int64
|
|
for field := range values {
|
|
token := tokens[index+field]
|
|
if token.Kind != dnfpvf.TokenInt {
|
|
return result, 0, fmt.Errorf("%s: record field %d at line %d is %s", currentDungeonIndependentDropPath, field, token.Line, token.Kind)
|
|
}
|
|
values[field] = token.Int
|
|
}
|
|
index += len(values)
|
|
|
|
listFlag := values[16]
|
|
var weighted []currentDungeonSpecialDropWeightedItem
|
|
var totalWeight uint32
|
|
if listFlag != 0 {
|
|
if index >= len(tokens) || !currentDungeonDropTokenSection(tokens[index], "list") {
|
|
return result, 0, fmt.Errorf("%s: list flag %d has no [list] after actor %d", currentDungeonIndependentDropPath, listFlag, values[1])
|
|
}
|
|
index++
|
|
listValues := make([]int64, 0, 8)
|
|
for index < len(tokens) && !currentDungeonDropTokenSection(tokens[index], "/list") {
|
|
if tokens[index].Kind != dnfpvf.TokenInt {
|
|
return result, 0, fmt.Errorf("%s: actor %d list token at line %d is %s", currentDungeonIndependentDropPath, values[1], tokens[index].Line, tokens[index].Kind)
|
|
}
|
|
listValues = append(listValues, tokens[index].Int)
|
|
index++
|
|
}
|
|
if index >= len(tokens) {
|
|
return result, 0, fmt.Errorf("%s: actor %d list is not closed", currentDungeonIndependentDropPath, values[1])
|
|
}
|
|
index++
|
|
if listFlag == 1 {
|
|
if len(listValues)%2 != 0 {
|
|
return result, 0, fmt.Errorf("%s: actor %d inline list has odd value count %d", currentDungeonIndependentDropPath, values[1], len(listValues))
|
|
}
|
|
var listErr error
|
|
weighted, totalWeight, listErr = currentDungeonSpecialDropWeightedItems(listValues)
|
|
if listErr != nil {
|
|
return result, 0, fmt.Errorf("%s: actor %d inline list: %w", currentDungeonIndependentDropPath, values[1], listErr)
|
|
}
|
|
} else if listFlag == 2 {
|
|
skippedListFlag2++
|
|
continue
|
|
} else {
|
|
return result, 0, fmt.Errorf("%s: actor %d unsupported list flag %d", currentDungeonIndependentDropPath, values[1], listFlag)
|
|
}
|
|
}
|
|
var destination map[int64][]currentDungeonIndependentDropEntry
|
|
switch values[0] {
|
|
case currentDungeonIndependentDropTypeMonster:
|
|
destination = result.ByMonster
|
|
case currentDungeonIndependentDropTypeAPC:
|
|
destination = result.ByAPC
|
|
default:
|
|
return result, 0, fmt.Errorf("%s: actor %d unsupported entry type %d", currentDungeonIndependentDropPath, values[1], values[0])
|
|
}
|
|
actorCode := values[1]
|
|
if actorCode <= 0 {
|
|
continue
|
|
}
|
|
entry := currentDungeonIndependentDropEntry{
|
|
EntryType: int(values[0]),
|
|
LevelMin: int(values[13]),
|
|
LevelMax: int(values[14]),
|
|
Difficulty: int(values[15]),
|
|
Items: weighted,
|
|
TotalWeight: totalWeight,
|
|
}
|
|
if values[2] > 0 && values[2] <= math.MaxUint32 {
|
|
entry.ItemID = uint32(values[2])
|
|
}
|
|
for difficulty := 0; difficulty < 5; difficulty++ {
|
|
probability := values[3+difficulty]
|
|
attempts := values[8+difficulty]
|
|
if probability < 0 || probability > math.MaxInt32 || attempts < 0 || attempts > math.MaxUint16 {
|
|
return result, 0, fmt.Errorf("%s: actor %d difficulty %d probability=%d attempts=%d", currentDungeonIndependentDropPath, actorCode, difficulty, probability, attempts)
|
|
}
|
|
entry.Probability[difficulty] = int(probability)
|
|
entry.Attempts[difficulty] = int(attempts)
|
|
}
|
|
destination[actorCode] = append(destination[actorCode], entry)
|
|
}
|
|
return result, skippedListFlag2, nil
|
|
}
|
|
|
|
func parseCurrentDungeonWorldDrops(text string) (map[int]currentDungeonWorldDropLevel, error) {
|
|
document, err := dnfpvf.Parse(currentDungeonWorldDropPath, text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tokens, found := document.Section("world drop")
|
|
if !found {
|
|
return nil, fmt.Errorf("%s: [world drop] section missing", currentDungeonWorldDropPath)
|
|
}
|
|
values := make([]int64, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
if token.Kind != dnfpvf.TokenInt {
|
|
return nil, fmt.Errorf("%s: token at line %d is %s", currentDungeonWorldDropPath, token.Line, token.Kind)
|
|
}
|
|
values = append(values, token.Int)
|
|
}
|
|
result := make(map[int]currentDungeonWorldDropLevel)
|
|
for index := 0; index < len(values); {
|
|
if index+2 > len(values) {
|
|
return nil, fmt.Errorf("%s: truncated level header at value %d", currentDungeonWorldDropPath, index)
|
|
}
|
|
level := values[index]
|
|
index += 2 // The second PVF column is preserved as opaque and not consumed by S4A12.
|
|
pairs := make([]int64, 0, 16)
|
|
terminated := false
|
|
for index < len(values) {
|
|
itemID := values[index]
|
|
index++
|
|
if itemID == -1 {
|
|
terminated = true
|
|
break
|
|
}
|
|
if index >= len(values) {
|
|
return nil, fmt.Errorf("%s: level %d item %d has no weight", currentDungeonWorldDropPath, level, itemID)
|
|
}
|
|
pairs = append(pairs, itemID, values[index])
|
|
index++
|
|
}
|
|
if !terminated {
|
|
return nil, fmt.Errorf("%s: level %d has no -1 terminator", currentDungeonWorldDropPath, level)
|
|
}
|
|
if level < 1 || level > currentDungeonWorldDropMaxLevel {
|
|
continue
|
|
}
|
|
items, totalWeight, listErr := currentDungeonSpecialDropWeightedItems(pairs)
|
|
if listErr != nil {
|
|
return nil, fmt.Errorf("%s: level %d: %w", currentDungeonWorldDropPath, level, listErr)
|
|
}
|
|
if totalWeight > 0 {
|
|
result[int(level)] = currentDungeonWorldDropLevel{Items: items, TotalWeight: totalWeight}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func currentDungeonSpecialDropWeightedItems(values []int64) ([]currentDungeonSpecialDropWeightedItem, uint32, error) {
|
|
items := make([]currentDungeonSpecialDropWeightedItem, 0, len(values)/2)
|
|
var total uint64
|
|
for index := 0; index+1 < len(values); index += 2 {
|
|
itemID, weight := values[index], values[index+1]
|
|
if itemID <= 0 || itemID > math.MaxUint32 || weight <= 0 || weight > math.MaxUint32 {
|
|
continue
|
|
}
|
|
total += uint64(weight)
|
|
if total > math.MaxUint32 {
|
|
return nil, 0, fmt.Errorf("weighted list total %d exceeds u32", total)
|
|
}
|
|
items = append(items, currentDungeonSpecialDropWeightedItem{ItemID: uint32(itemID), Weight: uint32(weight)})
|
|
}
|
|
return items, uint32(total), nil
|
|
}
|
|
|
|
// currentDungeonIndependentDropItems rolls the type-0 rows of one monster
|
|
// code. Type-1 (APC) rows sharing the same number are deliberately invisible
|
|
// here; see currentDungeonIndependentAPCDropItems.
|
|
func currentDungeonIndependentDropItems(
|
|
catalog *currentDungeonSpecialDropCatalog,
|
|
monsterID int64,
|
|
difficulty int,
|
|
dungeonLevel int,
|
|
bonusPercent int64,
|
|
rng *currentDungeonDropLCG,
|
|
) []uint32 {
|
|
if catalog == nil {
|
|
return nil
|
|
}
|
|
return rollCurrentDungeonIndependentDropEntries(catalog.IndependentByMonster[monsterID], difficulty, dungeonLevel, bonusPercent, rng)
|
|
}
|
|
|
|
// currentDungeonIndependentAPCDropItems rolls the type-1 rows of one APC
|
|
// code. No runtime death path calls it yet; it exists so the APC namespace has
|
|
// an owner and the type-1 probability semantics stay covered by tests.
|
|
func currentDungeonIndependentAPCDropItems(
|
|
catalog *currentDungeonSpecialDropCatalog,
|
|
apcCode int64,
|
|
difficulty int,
|
|
dungeonLevel int,
|
|
bonusPercent int64,
|
|
rng *currentDungeonDropLCG,
|
|
) []uint32 {
|
|
if catalog == nil {
|
|
return nil
|
|
}
|
|
return rollCurrentDungeonIndependentDropEntries(catalog.IndependentByAPC[apcCode], difficulty, dungeonLevel, bonusPercent, rng)
|
|
}
|
|
|
|
func rollCurrentDungeonIndependentDropEntries(
|
|
entries []currentDungeonIndependentDropEntry,
|
|
difficulty int,
|
|
dungeonLevel int,
|
|
bonusPercent int64,
|
|
rng *currentDungeonDropLCG,
|
|
) []uint32 {
|
|
if rng == nil {
|
|
return nil
|
|
}
|
|
if difficulty < 0 {
|
|
difficulty = 0
|
|
} else if difficulty > 4 {
|
|
difficulty = 4
|
|
}
|
|
result := make([]uint32, 0)
|
|
for _, entry := range entries {
|
|
if entry.LevelMin > 0 && entry.LevelMax > 0 && (dungeonLevel < entry.LevelMin || dungeonLevel > entry.LevelMax) {
|
|
continue
|
|
}
|
|
if entry.Difficulty >= 0 && entry.Difficulty != difficulty {
|
|
continue
|
|
}
|
|
attempts := entry.Attempts[difficulty]
|
|
if entry.Probability[difficulty] <= 0 || attempts <= 0 || (entry.ItemID == 0 && entry.TotalWeight == 0) {
|
|
continue
|
|
}
|
|
if entry.EntryType == 0 {
|
|
probability := currentDungeonIndependentDropProbability(entry.Probability[difficulty], bonusPercent)
|
|
for attempt := 0; attempt < attempts; attempt++ {
|
|
if probability <= rng.Next(currentDungeonIndependentDropDenominator) {
|
|
continue
|
|
}
|
|
if entry.TotalWeight > 0 {
|
|
if itemID, selected := currentDungeonSpecialDropWeightedSelect(entry.Items, entry.TotalWeight, rng); selected {
|
|
result = append(result, itemID)
|
|
}
|
|
} else if entry.ItemID > 0 {
|
|
result = append(result, entry.ItemID)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// Type 1 keeps probabilities above the denominator: the whole
|
|
// multiplier is granted per attempt and only the residual is rolled,
|
|
// with the reference's floor-rounded, uncapped bonus scaling.
|
|
probability := int64(entry.Probability[difficulty])
|
|
if bonusPercent > 0 {
|
|
probability = probability * (100 + bonusPercent) / 100
|
|
}
|
|
wholeMultiplier := probability / currentDungeonIndependentDropDenominator
|
|
residual := probability % currentDungeonIndependentDropDenominator
|
|
dropCount := wholeMultiplier * int64(attempts)
|
|
if residual != 0 {
|
|
for attempt := 0; attempt < attempts; attempt++ {
|
|
if residual > int64(rng.Next(currentDungeonIndependentDropDenominator)) {
|
|
dropCount++
|
|
}
|
|
}
|
|
}
|
|
for produced := int64(0); produced < dropCount; produced++ {
|
|
if entry.TotalWeight > 0 {
|
|
if itemID, selected := currentDungeonSpecialDropWeightedSelect(entry.Items, entry.TotalWeight, rng); selected {
|
|
result = append(result, itemID)
|
|
}
|
|
} else if entry.ItemID > 0 {
|
|
result = append(result, entry.ItemID)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// currentDungeonIndependentDropProbability applies the growth-contract bonus
|
|
// to a type-0 record: `prob * (100 + bonus) // 100` with floor rounding, then
|
|
// capped at the denominator (usdof_v54 game/drop/independent_drop.py
|
|
// `generate`; type 1 applies the same floor scaling without the cap).
|
|
func currentDungeonIndependentDropProbability(base int, bonusPercent int64) int {
|
|
if base <= 0 || bonusPercent <= 0 {
|
|
return base
|
|
}
|
|
if bonusPercent >= 10000 || int64(base) > math.MaxInt64/(100+bonusPercent) {
|
|
return currentDungeonIndependentDropDenominator
|
|
}
|
|
boosted := int64(base) * (100 + bonusPercent) / 100
|
|
if boosted > currentDungeonIndependentDropDenominator {
|
|
return currentDungeonIndependentDropDenominator
|
|
}
|
|
return int(boosted)
|
|
}
|
|
|
|
func currentDungeonWorldDropItem(
|
|
catalog *currentDungeonSpecialDropCatalog,
|
|
monsterLevel int,
|
|
rng *currentDungeonDropLCG,
|
|
) (uint32, bool) {
|
|
if catalog == nil || rng == nil || monsterLevel < 1 || monsterLevel > currentDungeonWorldDropMaxLevel {
|
|
return 0, false
|
|
}
|
|
level, found := catalog.WorldByLevel[monsterLevel]
|
|
if !found || level.TotalWeight == 0 || len(level.Items) == 0 {
|
|
return 0, false
|
|
}
|
|
if uint64(level.TotalWeight) <= uint64(rng.Next(currentDungeonWorldDropDenominator)) {
|
|
return 0, false
|
|
}
|
|
return currentDungeonSpecialDropWeightedSelect(level.Items, level.TotalWeight, rng)
|
|
}
|
|
|
|
func currentDungeonSpecialDropWeightedSelect(
|
|
items []currentDungeonSpecialDropWeightedItem,
|
|
totalWeight uint32,
|
|
rng *currentDungeonDropLCG,
|
|
) (uint32, bool) {
|
|
if rng == nil || totalWeight == 0 || len(items) == 0 {
|
|
return 0, false
|
|
}
|
|
want := rng.NextUint32() % totalWeight
|
|
var cumulative uint32
|
|
for _, item := range items {
|
|
if item.ItemID == 0 || item.Weight == 0 || math.MaxUint32-cumulative < item.Weight {
|
|
continue
|
|
}
|
|
cumulative += item.Weight
|
|
if want < cumulative {
|
|
return item.ItemID, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func currentDungeonDropSectionToken(tokens []dnfpvf.Token, name string) int {
|
|
for index, token := range tokens {
|
|
if currentDungeonDropTokenSection(token, name) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func currentDungeonDropTokenSection(token dnfpvf.Token, name string) bool {
|
|
return token.Kind == dnfpvf.TokenSection && strings.EqualFold(strings.TrimSpace(token.Value), name)
|
|
}
|