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,500 @@
package dnfbridge
import (
cryptorand "crypto/rand"
"fmt"
"math"
"math/big"
"sort"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func resolveCurrentBoosterDefinition(
catalog *pvfDungeonDropCatalog,
sourceItemID uint32,
requestKind currentBoosterRequestKind,
) (currentBoosterDefinition, error) {
if catalog == nil || catalog.source == nil || sourceItemID == 0 {
return currentBoosterDefinition{}, errCurrentBoosterPVFInvalid
}
sourceDefinition, err := catalog.ResolveItem(sourceItemID)
if err != nil {
return currentBoosterDefinition{}, fmt.Errorf("%w: resolve source item=%d: %v", errCurrentBoosterPVFInvalid, sourceItemID, err)
}
if sourceDefinition.Kind != dungeonDropItemStackable {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d is not stackable", errCurrentBoosterPVFInvalid, sourceItemID)
}
document, err := parseDungeonCardPVFDocument(catalog.source, sourceDefinition.PVFPath)
if err != nil {
return currentBoosterDefinition{}, fmt.Errorf("%w: read source item=%d: %v", errCurrentBoosterPVFInvalid, sourceItemID, err)
}
stackableType := normalizeDungeonDropStackableType(magicBoxDocumentText(document, "stackable type"))
giftAcquisitionOnly := currentMagicBoxHasImpossibleContent(document, "gift")
definition := currentBoosterDefinition{Source: sourceDefinition, StackableType: stackableType}
switch requestKind {
case currentBoosterRequestSelection:
if stackableType != "[booster selection]" {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d type=%q is not selectable", errCurrentBoosterPVFInvalid, sourceItemID, stackableType)
}
requiredValues := document.Ints("booster selection num")
if len(requiredValues) == 0 || requiredValues[0] < 0 || requiredValues[0] > currentBoosterMaxRewardRows {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d selection_num=%v", errCurrentBoosterPVFInvalid, sourceItemID, requiredValues)
}
definition.SelectionRequired = int(requiredValues[0])
definition.Selection, definition.SelectionCategory, err = parseCurrentBoosterSelectionCatalog(document)
if err != nil {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d: %v", errCurrentBoosterPVFInvalid, sourceItemID, err)
}
if len(definition.Selection) == 0 || len(definition.SelectionCategory) == 0 {
return currentBoosterDefinition{}, fmt.Errorf(
"%w: source item=%d selection_rows=%d selection_categories=%d",
errCurrentBoosterPVFInvalid,
sourceItemID,
len(definition.Selection),
len(definition.SelectionCategory),
)
}
case currentBoosterRequestRandom, currentBoosterRequestLottery:
if stackableType != "[booster]" && stackableType != "[cera booster]" && stackableType != "[booster random]" && stackableType != "[random upgradable legacy]" {
if requestKind != currentBoosterRequestLottery || stackableType != "[upgradable legacy]" {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d type=%q is not random", errCurrentBoosterPVFInvalid, sourceItemID, stackableType)
}
}
definition.Random, err = resolveCurrentMagicBox(catalog, catalog.source, int64(sourceItemID))
if err != nil {
return currentBoosterDefinition{}, err
}
// Top-level [need material] on an owned box is acquisition/shop
// metadata, not a second opening cost. Preserve only a true
// [sealing removal item] opening key (for example a magic hammer).
// This applies to both op160 booster boxes and op27 lottery jars.
if requestKind == currentBoosterRequestRandom || requestKind == currentBoosterRequestLottery {
definition.Random.MaterialItemID, definition.Random.MaterialCountPerUse =
resolveMagicBoxSealingRemovalMaterial(document, int64(sourceItemID))
}
if definition.Random.Kind != "random" || len(definition.Random.Groups) == 0 {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d has no random reward groups", errCurrentBoosterPVFInvalid, sourceItemID)
}
default:
return currentBoosterDefinition{}, errCurrentBoosterRequestMalformed
}
if materialValues := document.Ints("need material"); len(materialValues) > 0 && !giftAcquisitionOnly &&
requestKind == currentBoosterRequestSelection {
if len(materialValues) < 2 ||
materialValues[0] <= 0 ||
materialValues[1] <= 0 ||
materialValues[1] > currentBoosterMaxRewardUnits {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d need_material=%v", errCurrentBoosterPVFInvalid, sourceItemID, materialValues)
}
if _, resolveErr := catalog.ResolveItem(uint32(materialValues[0])); resolveErr != nil {
return currentBoosterDefinition{}, fmt.Errorf("%w: source item=%d need_material=%v: %v", errCurrentBoosterPVFInvalid, sourceItemID, materialValues, resolveErr)
}
definition.MaterialItemID = materialValues[0]
definition.MaterialCount = materialValues[1]
}
return definition, nil
}
func parseCurrentBoosterSelectionCandidates(document *dnfpvf.Document) ([]currentBoosterSelectionCandidate, error) {
candidates, _, err := parseCurrentBoosterSelectionCatalog(document)
return candidates, err
}
func parseCurrentBoosterSelectionCatalog(
document *dnfpvf.Document,
) ([]currentBoosterSelectionCandidate, map[uint16][]currentBoosterSelectionCandidate, error) {
if document == nil {
return nil, nil, errCurrentBoosterPVFInvalid
}
inCategory := false
var categoryID uint16
var upgradeLevel byte
result := make([]currentBoosterSelectionCandidate, 0, 8)
seen := make(map[uint32]currentBoosterSelectionCandidate)
categorySeen := make(map[uint16]map[uint32]currentBoosterSelectionCandidate)
categories := make(map[uint16][]currentBoosterSelectionCandidate)
for _, section := range document.Sections {
name := normalizeMagicBoxSectionName(section.Name)
switch name {
case "booster select category":
// Repeating the header implicitly ends the preceding category in
// historical files which omit each individual closing tag.
header := magicBoxSectionInts(document, section)
if len(header) == 0 ||
header[0] < 0 ||
header[0] > math.MaxUint8 ||
(len(header) > 1 && (header[1] < 0 || header[1] > math.MaxUint8)) {
return nil, nil, fmt.Errorf("invalid booster selection category header=%v", header)
}
// Current NoPack writes the two category header bytes separately
// immediately after source_slot. Keep the same little-endian key
// instead of discarding the grow-type/subcategory byte.
categoryID = uint16(header[0])
if len(header) > 1 {
categoryID |= uint16(header[1]) << 8
}
inCategory = true
upgradeLevel = 0
if _, exists := categories[categoryID]; !exists {
categories[categoryID] = make([]currentBoosterSelectionCandidate, 0, 8)
categorySeen[categoryID] = make(map[uint32]currentBoosterSelectionCandidate)
}
continue
case "/booster select category":
if !inCategory {
// A small set of historical files contains a redundant category
// close after a sequence of individually closed categories.
// It carries no rewards and can be ignored without widening the
// accepted item catalog.
continue
}
inCategory = false
continue
}
if !inCategory || strings.HasPrefix(name, "/") {
continue
}
values := magicBoxSectionInts(document, section)
// [booster equipment upgrade] 不是奖励行,而是**该分类内装备奖励的强化等级**:
// 它写在分类头之后、equipment 段之前,作用域是"本分类"。升级预约活动
// (onlineevent level_aiming,活动 222)发的武器就靠这个值决定 +11 之类,
// 所以不能像参考实现以外的那样当"已知但忽略"。计数为 0 的分类不受影响。
if name == "booster equipment upgrade" {
if len(values) != 1 || values[0] < 0 || values[0] > math.MaxUint8 {
return nil, nil, fmt.Errorf("invalid booster equipment upgrade=%v", values)
}
upgradeLevel = byte(values[0])
continue
}
if len(values) == 0 {
continue
}
stride := 0
switch name {
case "booster category num", "booster selection num", "default select", "booster equipment grade", "booster equipment separate", "random probability":
// Historical selection boxes keep client-side defaults and a
// display-defaults hint inside each category. Neither section is
// a reward row: the following equipment/stackable/avatar sections
// remain the authoritative selectable catalog.
continue
case "avatar":
// Avatar choices carry item/count plus the client attribute
// category and option written in the 10-byte op160 request.
stride = 4
case "cera", "stackable", "stackble", "creature", "equipment", "etc":
// Ordinary items, pets and enchant orbs are simple item/count
// pairs. Treating two adjacent pairs as one avatar quad made the
// second item look like an option byte and rejected valid choices.
stride = 2
default:
return nil, nil, fmt.Errorf("selection section=%q with %d integer values has unsupported runtime-PVF shape", section.Name, len(values))
}
if len(values)%stride != 0 {
return nil, nil, fmt.Errorf("selection section=%q values=%d is not divisible by stride=%d", section.Name, len(values), stride)
}
for offset := 0; offset < len(values); offset += stride {
itemID, count := values[offset], values[offset+1]
categoryKind, option := int64(0), int64(0)
if stride == 4 {
categoryKind, option = values[offset+2], values[offset+3]
}
if itemID <= 0 || itemID > math.MaxUint32 || count <= 0 || count > currentBoosterMaxRewardUnits || option < -1 || option > math.MaxUint8 {
return nil, nil, fmt.Errorf("selection section=%q invalid row=%v", section.Name, values[offset:offset+stride])
}
if option == -1 {
// -1 means no PVF default. Avatar choices receive their actual
// option from the current op160 request after category validation.
option = 0
}
candidate := currentBoosterSelectionCandidate{
ItemID: uint32(itemID),
Count: uint32(count),
CategoryKind: categoryKind,
Option: byte(option),
}
// 强化等级只作用于**装备**行:stackable/avatar/creature 拿到它没有意义,
// 而且分类之间不能互相泄漏(升级预约的分类 != 普通选择的分类)。
if name == "equipment" {
candidate.UpgradeLevel = upgradeLevel
}
if _, duplicate := seen[candidate.ItemID]; !duplicate {
seen[candidate.ItemID] = candidate
result = append(result, candidate)
}
if previous, duplicate := categorySeen[categoryID][candidate.ItemID]; duplicate {
if previous != candidate {
return nil, nil, fmt.Errorf("selection category=%d item=%d has conflicting rows", categoryID, candidate.ItemID)
}
// Some historical event files repeat an identical job category or
// list the same weapon twice. Keep one authoritative choice.
continue
}
categorySeen[categoryID][candidate.ItemID] = candidate
categories[categoryID] = append(categories[categoryID], candidate)
}
}
// Some historical event definitions terminate the last selection category
// at end-of-file rather than writing a closing tag. A category header was
// still required before every accepted reward row, so EOF is a safe implicit
// close and does not admit unscoped rewards.
for id, candidates := range categories {
// Job/grow-type selection documents retain empty category placeholders
// for combinations the client does not offer (for example a job without
// a fifth advancement). They do not invalidate populated categories.
// currentBoosterSelectedCandidates still rejects an empty category if a
// forged request explicitly selects one.
if len(candidates) > currentBoosterMaxRewardRows {
return nil, nil, fmt.Errorf("booster selection category=%d rows=%d", id, len(candidates))
}
}
return result, categories, nil
}
func currentBoosterSelectedCandidates(
definition currentBoosterDefinition,
request currentBoosterOpenRequest,
) ([]currentBoosterSelectionCandidate, error) {
if len(request.Selections) == 0 {
return nil, errCurrentBoosterSelectionInvalid
}
category, found := definition.SelectionCategory[request.SelectionContext]
if !found || len(category) == 0 {
return nil, fmt.Errorf("%w: category=%d is not in source PVF", errCurrentBoosterSelectionInvalid, request.SelectionContext)
}
required := definition.SelectionRequired
if required == 0 {
required = len(category)
}
if required <= 0 || len(request.Selections) != required {
return nil, fmt.Errorf(
"%w: category=%d selected=%d want=%d",
errCurrentBoosterSelectionInvalid,
request.SelectionContext,
len(request.Selections),
required,
)
}
byItem := make(map[uint32]currentBoosterSelectionCandidate, len(category))
for _, candidate := range category {
byItem[candidate.ItemID] = candidate
}
selected := make([]currentBoosterSelectionCandidate, 0, len(request.Selections))
seen := make(map[uint32]struct{}, len(request.Selections))
for _, choice := range request.Selections {
if choice.ItemID == 0 {
return nil, errCurrentBoosterSelectionInvalid
}
if _, duplicate := seen[choice.ItemID]; duplicate {
return nil, fmt.Errorf("%w: duplicate item=%d", errCurrentBoosterSelectionInvalid, choice.ItemID)
}
seen[choice.ItemID] = struct{}{}
candidate, found := byItem[choice.ItemID]
if !found {
return nil, fmt.Errorf(
"%w: item=%d is not in source PVF category=%d",
errCurrentBoosterSelectionInvalid,
choice.ItemID,
request.SelectionContext,
)
}
if candidate.CategoryKind != 0 {
// Avatar category rows carry the client-selected attribute byte.
// The fourth PVF integer is not that runtime choice: the live
// eight-piece writer sends different values for hat/hair/top/etc.
candidate.Option = choice.Option
} else if candidate.Option != choice.Option {
return nil, fmt.Errorf("%w: item=%d option=%d want=%d", errCurrentBoosterSelectionInvalid, choice.ItemID, choice.Option, candidate.Option)
}
selected = append(selected, candidate)
}
return selected, nil
}
func validateCurrentBoosterSourceExpiration(stack dnfrepo.ItemStack, definition dungeonDropItemDefinition, now time.Time) error {
if now.IsZero() {
now = time.Now().UTC()
}
if expire := currentItemListStackExpire(stack); expire != 0 {
if uint64(expire) <= uint64(now.Unix()) {
return errCurrentBoosterExpired
}
return nil
}
if definition.UsablePeriodDays > 0 && currentItemListStackExpire(stack) == 0 {
return errCurrentBoosterExpired
}
if !definition.ExpirationDate.IsZero() && !now.Before(definition.ExpirationDate) {
return errCurrentBoosterExpired
}
return nil
}
func resolveCurrentBoosterRewardAmounts(
definition currentBoosterDefinition,
request currentBoosterOpenRequest,
roll func(int64) (int64, error),
) (map[uint32]uint32, map[uint32]byte, uint32, error) {
amounts := make(map[uint32]uint32)
options := make(map[uint32]byte)
gold := uint64(0)
switch request.Kind {
case currentBoosterRequestSelection:
candidates, err := currentBoosterSelectedCandidates(definition, request)
if err != nil {
return nil, nil, 0, err
}
for _, candidate := range candidates {
current := uint64(amounts[candidate.ItemID])
if current+uint64(candidate.Count) > currentBoosterMaxRewardUnits {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
amounts[candidate.ItemID] = uint32(current + uint64(candidate.Count))
options[candidate.ItemID] = candidate.Option
}
case currentBoosterRequestRandom, currentBoosterRequestLottery:
if roll == nil {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
for _, group := range definition.Random.Groups {
totalWeight := int64(0)
for _, entry := range group.Entries {
validItem := !entry.Gold && entry.ItemID > 0 && entry.ItemID <= math.MaxUint32
validGold := entry.Gold && entry.ItemID == 0
if (!validItem && !validGold) || entry.Weight < 0 || entry.Count <= 0 || entry.Count > currentBoosterMaxRewardUnits {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
if entry.Weight > math.MaxInt64-totalWeight {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
totalWeight += entry.Weight
}
if totalWeight <= 0 {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
drawCount := group.DrawCount
if drawCount < 1 {
drawCount = 1
}
if drawCount > currentBoosterMaxRewardRows {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
for draw := int64(0); draw < drawCount; draw++ {
value, err := roll(totalWeight)
if err != nil || value < 0 || value >= totalWeight {
return nil, nil, 0, fmt.Errorf("%w: random roll: %v", errCurrentBoosterPVFInvalid, err)
}
cumulative := int64(0)
for _, entry := range group.Entries {
cumulative += entry.Weight
if value >= cumulative {
continue
}
if entry.Gold {
if gold+uint64(entry.Count) > math.MaxInt32 {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
gold += uint64(entry.Count)
break
}
current := uint64(amounts[uint32(entry.ItemID)])
if current+uint64(entry.Count) > currentBoosterMaxRewardUnits {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
amounts[uint32(entry.ItemID)] = uint32(current + uint64(entry.Count))
options[uint32(entry.ItemID)] = entry.Option
break
}
}
}
default:
return nil, nil, 0, errCurrentBoosterRequestMalformed
}
rewardRows := len(amounts)
if gold != 0 {
rewardRows++
}
if rewardRows == 0 || rewardRows > currentBoosterMaxRewardRows {
return nil, nil, 0, errCurrentBoosterPVFInvalid
}
return amounts, options, uint32(gold), nil
}
func secureCurrentBoosterRoll(limit int64) (int64, error) {
if limit <= 0 {
return 0, errCurrentBoosterPVFInvalid
}
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(limit))
if err != nil {
return 0, err
}
return value.Int64(), nil
}
func resolveCurrentBoosterRewards(
catalog *pvfDungeonDropCatalog,
amounts map[uint32]uint32,
options map[uint32]byte,
sourceStack dnfrepo.ItemStack,
now time.Time,
) ([]currentBoosterReward, error) {
itemIDs := make([]uint32, 0, len(amounts))
for itemID := range amounts {
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(i, j int) bool { return itemIDs[i] < itemIDs[j] })
rewards := make([]currentBoosterReward, 0, len(itemIDs))
for _, itemID := range itemIDs {
count := amounts[itemID]
definition, err := catalog.ResolveItem(itemID)
if err != nil {
return nil, fmt.Errorf("%w: resolve reward item=%d: %v", errCurrentBoosterPVFInvalid, itemID, err)
}
definition, err = currentPVFItemDefinitionForNestedRewardGrantAt(definition, sourceStack, now)
if err != nil {
return nil, fmt.Errorf("%w: reward item=%d expiration: %v", errCurrentBoosterPVFInvalid, itemID, err)
}
document, err := parseDungeonCardPVFDocument(catalog.source, definition.PVFPath)
if err != nil {
return nil, fmt.Errorf("%w: reward item=%d: %v", errCurrentBoosterPVFInvalid, itemID, err)
}
attachType := normalizeMagicBoxPVFType(magicBoxDocumentText(document, "attach type"))
placement, err := currentBoosterPlacement(definition)
if err != nil {
return nil, err
}
rewards = append(rewards, currentBoosterReward{
Definition: definition,
Count: count,
Option: options[itemID],
Seal: attachType == "sealing",
Placement: placement,
})
}
return rewards, nil
}
func currentBoosterPlacement(definition dungeonDropItemDefinition) (currentBoosterRewardPlacement, error) {
if definition.Kind == dungeonDropItemStackable {
if isCurrentCeraShopPetConsumable(definition) {
return currentBoosterRewardPetConsumable, nil
}
return currentBoosterRewardMain, nil
}
if definition.Kind != dungeonDropItemEquipment {
return currentBoosterRewardMain, errCurrentBoosterPVFInvalid
}
if isCurrentCeraShopCreatureItem(definition) {
return currentBoosterRewardPetBody, nil
}
if rule, supported := currentEquipmentPlacementRuleForPVFType(definition.EquipmentType); supported && rule.class == currentEquipmentPlacementClassAvatar {
return currentBoosterRewardAvatar, nil
}
normalizedType := normalizeMagicBoxPVFType(definition.EquipmentType)
if strings.HasPrefix(normalizedType, "artifact ") {
return currentBoosterRewardPetArtifact, nil
}
return currentBoosterRewardMain, nil
}