Files
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

482 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}