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

501 lines
20 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 (
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
}