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,481 @@
package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"time"
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
)
const currentHellPartyDropChanceDenominator = 1001
const (
// The 90CN PVF owns equipment rarity and level selection, but the retail
// server kept the invitation-orb jackpot outside Script.pvf. Contemporary
// 90-era aggregate records put it at roughly 0.54% per Hell clear.
currentDungeonHellOrbRollDenominator = 1_000_000
currentDungeonHellOrbChance = 5_400
// 阴沉的深渊宝珠 for dungeons below level 86, 地轨中心深渊宝珠 from 86 up.
currentDungeonHellGenericOrbItemID = uint32(10146492)
currentDungeonHellMetroOrbItemID = uint32(10159141)
currentDungeonHellOrbDropSource = "hell_invitation_orb"
)
var errCurrentHellPartyDropUnavailable = errors.New("dnf hell party drop assets are unavailable")
// currentDungeonHellPartyOrbItemID picks the era-correct orb family for the
// dungeon's actor level.
func currentDungeonHellPartyOrbItemID(level int) uint32 {
if level >= 86 {
return currentDungeonHellMetroOrbItemID
}
return currentDungeonHellGenericOrbItemID
}
type currentDungeonHellPartyFragmentCandidate struct {
EquipmentID uint32
FragmentID uint32
Weight uint32
}
type currentDungeonHellPartySelectedDrop struct {
ItemID uint32
Amount uint32
Source string
}
func (s *Service) currentHellPartyDropAssets() (*dnfhellparty.DropCatalog, map[int][]currentDungeonHellPartyFragmentCandidate, error) {
if s == nil {
return nil, nil, errCurrentHellPartyDropUnavailable
}
s.hellPartyDropMu.Lock()
defer s.hellPartyDropMu.Unlock()
if s.hellPartyDropCatalog != nil || s.hellPartyDropCatalogLoadErr != nil {
return s.hellPartyDropCatalog, s.hellPartyFragmentPools, s.hellPartyDropCatalogLoadErr
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
dropCatalog, err := dnfhellparty.LoadDropCatalog(archive)
if err != nil {
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
items, err := s.currentPVFItemCatalog()
if err != nil {
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
epicBook, err := s.currentEpicBookCatalog()
if err != nil {
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
fragmentPools := make(map[int][]currentDungeonHellPartyFragmentCandidate)
for _, entry := range epicBook.Entries() {
equipment, equipmentErr := items.ResolveItem(entry.EquipmentID)
fragment, fragmentErr := items.ResolveItem(entry.FragmentID)
if equipmentErr != nil || fragmentErr != nil || equipment.Kind != dungeonDropItemEquipment ||
equipment.Rarity != 4 || equipment.MinimumLevel <= 0 || equipment.MinimumLevel > 200 ||
fragment.Kind != dungeonDropItemStackable {
err = fmt.Errorf("%w: equipment=%d fragment=%d equipment_error=%v fragment_error=%v",
errCurrentHellPartyDropUnavailable, entry.EquipmentID, entry.FragmentID, equipmentErr, fragmentErr)
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
weight := equipment.CreationRate
if weight <= 0 {
weight = 1
}
if weight > math.MaxUint32 {
weight = math.MaxUint32
}
level := int(equipment.MinimumLevel)
fragmentPools[level] = append(fragmentPools[level], currentDungeonHellPartyFragmentCandidate{
EquipmentID: entry.EquipmentID,
FragmentID: entry.FragmentID,
Weight: uint32(weight),
})
}
if len(fragmentPools) == 0 {
err = fmt.Errorf("%w: epic fragment candidate pool is empty", errCurrentHellPartyDropUnavailable)
s.hellPartyDropCatalogLoadErr = err
return nil, nil, err
}
for level := range fragmentPools {
sort.Slice(fragmentPools[level], func(i, j int) bool {
return fragmentPools[level][i].EquipmentID < fragmentPools[level][j].EquipmentID
})
}
s.hellPartyDropCatalog = dropCatalog
s.hellPartyFragmentPools = fragmentPools
return s.hellPartyDropCatalog, s.hellPartyFragmentPools, nil
}
func (s *Service) preloadHellPartyDropCatalog(ctx context.Context) error {
if s == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
started := time.Now()
catalog, pools, err := s.currentHellPartyDropAssets()
if err != nil {
return fmt.Errorf("preload current hell party drop catalog: %w", err)
}
probabilityRows, rarityRows, gradeRows := catalog.Snapshot()
candidateCount := 0
for _, candidates := range pools {
candidateCount += len(candidates)
}
s.logPacketEvent("dnf-hell-party-drop-catalog-loaded",
"probability_rows", probabilityRows,
"rarity_rows", rarityRows,
"grade_rows", gradeRows,
"fragment_levels", len(pools),
"fragment_candidates", candidateCount,
"elapsed_ms", time.Since(started).Milliseconds())
return nil
}
func currentHellPartyEpicLevels(monsterLevel int) []int {
if monsterLevel < 50 {
monsterLevel = 50
}
// 史诗碎片池最高只有 90 档(图鉴里就是 65/70/75/80/85/90 六档)。
// 所以「怪物等级 > 90」要按 90 处理,而不是直接返回 {90}:
// 直接返回 {90} 会让所有 91 级以上的深渊(例如红色魔女之森 basis level = 92)
// 永远刷不出 85 级史诗 —— 但 85 档池子里有 138 件,本来是应该掉的。
// 钳到 90 之后,92 级副本得到 lower=85 / upper=90,与 90 级副本一致。
if monsterLevel > 90 {
monsterLevel = 90
}
upper := ((monsterLevel + 4) / 5) * 5
if upper < 50 {
upper = 50
}
lower := upper - 5
if lower < 50 {
lower = 50
}
if lower == upper {
return []int{upper}
}
return []int{lower, upper}
}
func selectCurrentHellPartyFragmentCandidate(
rng *currentDungeonDropLCG,
pools map[int][]currentDungeonHellPartyFragmentCandidate,
monsterLevel int,
) (currentDungeonHellPartyFragmentCandidate, bool) {
if rng == nil {
return currentDungeonHellPartyFragmentCandidate{}, false
}
candidates := make([]currentDungeonHellPartyFragmentCandidate, 0)
for _, level := range currentHellPartyEpicLevels(monsterLevel) {
candidates = append(candidates, pools[level]...)
}
return selectCurrentHellPartyFragmentCandidateFromPool(rng, candidates)
}
func selectCurrentHellPartyFragmentCandidateFromPool(
rng *currentDungeonDropLCG,
candidates []currentDungeonHellPartyFragmentCandidate,
) (currentDungeonHellPartyFragmentCandidate, bool) {
if rng == nil {
return currentDungeonHellPartyFragmentCandidate{}, false
}
var total uint64
for _, candidate := range candidates {
if candidate.FragmentID == 0 || candidate.Weight == 0 || math.MaxUint64-total < uint64(candidate.Weight) {
continue
}
total += uint64(candidate.Weight)
}
if total == 0 {
return currentDungeonHellPartyFragmentCandidate{}, false
}
want := uint64(rng.NextUint32()) % total
var cumulative uint64
for _, candidate := range candidates {
if candidate.FragmentID == 0 || candidate.Weight == 0 || math.MaxUint64-cumulative < uint64(candidate.Weight) {
continue
}
cumulative += uint64(candidate.Weight)
if want < cumulative {
return candidate, true
}
}
return currentDungeonHellPartyFragmentCandidate{}, false
}
func (s *Service) planCurrentDungeonHellPartyDrops(
runtime *runtimeDungeonState,
actor currentDungeonHellPartyActor,
ownerActorObjectKey uint16,
rollCount uint32,
) ([]currentDungeonDeathDropWire, []currentDungeonHellPartySelectedDrop, error) {
if runtime == nil || runtime.Session == nil || runtime.Room == nil || runtime.HellParty == nil {
return nil, nil, errDungeonWorldMapUnavailable
}
return s.planCurrentDungeonHellDrops(
runtime,
actor.Level,
ownerActorObjectKey,
rollCount,
runtime.HellParty.Mode,
)
}
func (s *Service) planCurrentDungeonStandaloneRiftBossDrops(
runtime *runtimeDungeonState,
monster runtimeDungeonMonster,
ownerActorObjectKey uint16,
) ([]currentDungeonDeathDropWire, []currentDungeonHellPartySelectedDrop, error) {
if !currentDungeonStandaloneRiftRuntime(runtime) {
return nil, nil, errDungeonWorldMapUnavailable
}
monsterType, err := currentDungeonMonsterType(monster.Spawn.Rank)
if err != nil {
return nil, nil, err
}
if monsterType != 3 {
return nil, nil, nil
}
monsterLevel, err := currentDungeonMonsterLevel(monster.Spawn, runtime)
if err != nil {
return nil, nil, err
}
rollCount, err := s.currentDungeonStandaloneRiftRewardRollCount()
if err != nil {
return nil, nil, err
}
return s.planCurrentDungeonHellDrops(
runtime,
monsterLevel,
ownerActorObjectKey,
rollCount,
currentHellPartyModeA,
)
}
func (s *Service) planCurrentDungeonHellDrops(
runtime *runtimeDungeonState,
actorLevel byte,
ownerActorObjectKey uint16,
rollCount uint32,
hellPartyMode byte,
) ([]currentDungeonDeathDropWire, []currentDungeonHellPartySelectedDrop, error) {
if runtime == nil || runtime.Session == nil || runtime.Room == nil {
return nil, nil, errDungeonWorldMapUnavailable
}
if ownerActorObjectKey == 0 {
return nil, nil, errDungeonDropActorMismatch
}
scene, ok := runtime.Session.Scene()
if !ok {
return nil, nil, errDungeonWorldMapUnavailable
}
visit, err := runtime.currentDungeonRoomVisit(scene)
if err != nil {
return nil, nil, err
}
hellDrops, fragmentPools, err := s.currentHellPartyDropAssets()
if err != nil {
return nil, nil, err
}
monsterCatalog, err := s.dungeonMonsterCatalog()
if err != nil {
return nil, nil, err
}
items, err := monsterCatalog.DropCatalog()
if err != nil {
return nil, nil, err
}
fallbacksBefore := items.GenericFallbackMarks()
basisLevel := actorLevel
if runtime.Dungeon.Metadata.BasisLevel.Set && runtime.Dungeon.Metadata.BasisLevel.Value > 0 {
basisLevel = byte(runtime.Dungeon.Metadata.BasisLevel.Value)
}
if basisLevel == 0 {
return nil, nil, fmt.Errorf("%w: basis level is zero", errCurrentHellPartyDropUnavailable)
}
window, windowFound := hellDrops.GradeWindow(int(basisLevel))
rate, rateFound := hellDrops.DropRate(int(basisLevel), int(runtime.Request.Difficulty))
if !windowFound || !rateFound {
return nil, nil, fmt.Errorf("%w: basis=%d difficulty=%d window=%t rate=%t", errCurrentHellPartyDropUnavailable, basisLevel, runtime.Request.Difficulty, windowFound, rateFound)
}
rarityWeights, weightsFound := hellDrops.RarityWeights(int(hellPartyMode))
if !weightsFound {
return nil, nil, fmt.Errorf("%w: hell-party rarity weights are unavailable", errCurrentHellPartyDropUnavailable)
}
baseEpicRate := int64(rarityWeights[4])
epicPercent := s.currentHellPartyEpicDropRatePercent(runtime.Character)
rng := newCurrentDungeonDropLCG(visit.DropRNG)
selected := make([]currentDungeonHellPartySelectedDrop, 0, rollCount*2)
timeRift := currentDungeonTimeRiftRuntime(runtime)
var wishItems []uint32
if timeRift {
wishItems, err = s.currentEpicWishItemsForCharacter(runtime.Character)
if err != nil {
s.logPacketEvent("dnf-epic-wish-drop-preference-sanitized",
"character_id", runtime.Character.CharacterID,
"dungeon_id", runtime.Request.DungeonID,
"reason", err)
wishItems = nil
}
}
for index := uint32(0); index < rollCount; index++ {
chance := rng.Next(currentHellPartyDropChanceDenominator)
if uint32(chance) > rate {
continue
}
rarityRoll := uint32(rng.Next(int(dnfhellparty.RarityRollDenominator)))
rarity, rarityFound := hellDrops.RarityForRoll(int(hellPartyMode), rarityRoll)
if !rarityFound {
continue
}
if epicPercent != gmHellEpicDropRateDefaultPercent {
adjustRoll := uint32(rng.Next(int(dnfhellparty.RarityRollDenominator)))
rarity, rarityFound = gmAdjustHellEpicRarity(rarity, baseEpicRate, epicPercent, adjustRoll)
if !rarityFound {
continue
}
}
var itemID uint32
var itemFound bool
itemSource := "hell_equipment"
if rarity == 4 {
candidate, found := currentDungeonHellPartyFragmentCandidate{}, false
wishHit := false
if timeRift {
candidate, found, wishHit = selectCurrentTimeRiftEpicCandidate(
rng,
fragmentPools,
int(actorLevel),
wishItems,
)
} else {
candidate, found = selectCurrentHellPartyFragmentCandidate(rng, fragmentPools, int(actorLevel))
}
if found {
itemID, itemFound = candidate.EquipmentID, true
if wishHit {
itemSource = "hell_wish_equipment"
}
}
} else {
itemID, itemFound, err = items.SelectGenericEquipment(rng, int(basisLevel), window.Down, window.Up, rarity)
if err != nil {
return nil, nil, err
}
}
if itemFound {
selected = append(selected, currentDungeonHellPartySelectedDrop{ItemID: itemID, Amount: 1, Source: itemSource})
}
}
for index := uint32(0); index < rollCount; index++ {
candidate, found := selectCurrentHellPartyFragmentCandidate(rng, fragmentPools, int(actorLevel))
if found {
selected = append(selected, currentDungeonHellPartySelectedDrop{ItemID: candidate.FragmentID, Amount: 1, Source: "epic_fragment"})
}
}
// The invitation-orb jackpot is an independent rare roll on a Hell-party
// clear only; the standalone Time Rift boss shares this planner but never
// paid orbs. It is rolled after the PVF-driven streams so their RNG sequence
// stays identical to the reference with the jackpot disabled. An orb the
// active item catalog cannot resolve is skipped rather than failing the
// whole reward plan.
if runtime.HellParty != nil && rng.Next(currentDungeonHellOrbRollDenominator) < currentDungeonHellOrbChance {
orbItemID := currentDungeonHellPartyOrbItemID(int(actorLevel))
if _, resolveErr := items.ResolveItem(orbItemID); resolveErr == nil {
selected = append(selected, currentDungeonHellPartySelectedDrop{ItemID: orbItemID, Amount: 1, Source: currentDungeonHellOrbDropSource})
} else {
s.logPacketEvent("dnf-hell-party-orb-drop-skipped",
"dungeon_id", runtime.Request.DungeonID,
"item_id", orbItemID,
"reason", resolveErr)
}
}
roomKey := runtimeDungeonRoomKeyFromScene(scene)
owner := runtime.DropOwner
if owner == nil {
owner = newRuntimeDungeonDropOwner()
}
nextObjectKey := owner.nextObjectKeyAtOrAbove(runtime.NextObjectKey)
count := uint32(len(selected))
if count > math.MaxUint8 || nextObjectKey == 0 || nextObjectKey > math.MaxUint16 ||
count > math.MaxUint16-nextObjectKey {
return nil, nil, fmt.Errorf("%w: next=%d count=%d", errDungeonDropObjectKeyRange, nextObjectKey, count)
}
if owner.nextSceneSlot == 0 || uint32(owner.nextSceneSlot)+count > math.MaxUint16 {
return nil, nil, fmt.Errorf("%w: next=%d count=%d", errDungeonDropSceneSlotRange, owner.nextSceneSlot, count)
}
drops := make([]*runtimeDungeonDrop, 0, len(selected))
wires := make([]currentDungeonDeathDropWire, 0, len(selected))
nextSceneSlot := owner.nextSceneSlot
for _, planned := range selected {
definition, resolveErr := items.ResolveItem(planned.ItemID)
if resolveErr != nil {
return nil, nil, resolveErr
}
itemState, stateErr := currentDungeonDeathDropItemState(nextSceneSlot, definition, planned.Amount)
if stateErr != nil {
return nil, nil, stateErr
}
qualitySeed := uint32(0)
if definition.Kind == dungeonDropItemEquipment {
qualitySeed = binary.LittleEndian.Uint32(itemState.data[6:10])
}
drop := &runtimeDungeonDrop{
ObjectKey: nextObjectKey,
SceneSlot: nextSceneSlot,
Item: definition,
Amount: planned.Amount,
QualitySeed: qualitySeed,
OwnerActorObjectKey: ownerActorObjectKey,
Room: roomKey,
Status: runtimeDungeonDropAvailable,
}
drops = append(drops, drop)
wires = append(wires, currentDungeonDeathDropWire{
SceneObjectKey: drop.ObjectKey,
Item: itemState,
UnknownTailSentinel: math.MaxUint16,
OwnerActorObjectKey: ownerActorObjectKey,
})
nextObjectKey++
nextSceneSlot++
}
if len(drops) > 0 {
if err := owner.registerBatch(drops, nextSceneSlot); err != nil {
return nil, nil, err
}
runtime.DropOwner = owner
runtime.NextObjectKey = nextObjectKey
}
visit.DropRNG = rng.Seed()
s.reportCurrentDungeonGenericDropFallbacks(items, fallbacksBefore,
"game-dungeon-hell-drop-empty-rarity-pool",
"dungeon_id", runtime.Request.DungeonID,
"maze_index", runtime.MazeIndex,
"basis_level", basisLevel,
"hell_party_mode", hellPartyMode,
"roll_count", rollCount)
return wires, selected, nil
}