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,467 @@
package dnfbridge
import (
cryptorand "crypto/rand"
"errors"
"fmt"
"math"
"math/big"
"sort"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentEquipmentRegenerationPath = "etc/randomoption/regenerationrandomoption.etc"
currentEquipmentRegenerationOverallPath = "etc/randomoption/randomizedoptionoverall1.etc"
)
type currentEquipmentRegenerationMaterial struct {
itemID int64
count int64
}
type currentEquipmentRegenerationLevelWeight struct {
current int64
plusFive int64
}
type currentEquipmentRegenerationCandidate struct {
itemID int64
pvfPath string
equipmentType string
itemGroup string
attachType string
itemCategory string
rarity int64
minimumLevel int64
creationRate int64
durability uint16
weight int64
legacy bool
}
type currentEquipmentRegenerationCatalog struct {
parts map[uint16]map[string]bool
excluded map[int64]bool
exceptionWeights map[int64]int64
materials map[int64][]currentEquipmentRegenerationMaterial
costs map[int64]map[int64]int64
levelWeights map[int64]map[int64]currentEquipmentRegenerationLevelWeight
candidates map[[2]int64][]currentEquipmentRegenerationCandidate
byItemID map[int64]currentEquipmentRegenerationCandidate
}
func (s *Service) alignedEquipmentRegenerationResolverForCommand(opcode dnfenum.CmdPacket) (alignedcmd.EquipmentRegenerationResolver, error) {
if opcode != dnfenum.CmdPacketRegenerationRandomOption {
return nil, nil
}
catalog, err := s.cachedEquipmentRegenerationCatalog()
if err != nil {
return nil, err
}
return func(sourceItemID int64, mode uint16, part uint16) (alignedcmd.EquipmentRegenerationResolution, error) {
return catalog.resolve(sourceItemID, mode, part)
}, nil
}
func (s *Service) cachedEquipmentRegenerationCatalog() (*currentEquipmentRegenerationCatalog, error) {
if s == nil {
return nil, errDungeonDropSourceRequired
}
s.equipmentRegenerationMu.Lock()
defer s.equipmentRegenerationMu.Unlock()
if s.equipmentRegenerationCatalog == nil && s.equipmentRegenerationLoadErr == nil {
catalog, err := s.currentPVFItemCatalog()
if err == nil {
s.equipmentRegenerationCatalog, err = loadCurrentEquipmentRegenerationCatalog(catalog)
}
s.equipmentRegenerationLoadErr = err
}
if s.equipmentRegenerationLoadErr != nil {
return nil, s.equipmentRegenerationLoadErr
}
return s.equipmentRegenerationCatalog, nil
}
func loadCurrentEquipmentRegenerationCatalog(items *pvfDungeonDropCatalog) (*currentEquipmentRegenerationCatalog, error) {
if items == nil || items.source == nil {
return nil, errDungeonDropSourceRequired
}
configDocument, err := parseCurrentEquipmentRegenerationDocument(items.source, currentEquipmentRegenerationPath)
if err != nil {
return nil, err
}
overallDocument, err := parseCurrentEquipmentRegenerationDocument(items.source, currentEquipmentRegenerationOverallPath)
if err != nil {
return nil, err
}
catalog := &currentEquipmentRegenerationCatalog{
parts: make(map[uint16]map[string]bool),
excluded: make(map[int64]bool),
exceptionWeights: make(map[int64]int64),
materials: make(map[int64][]currentEquipmentRegenerationMaterial),
costs: make(map[int64]map[int64]int64),
levelWeights: make(map[int64]map[int64]currentEquipmentRegenerationLevelWeight),
candidates: make(map[[2]int64][]currentEquipmentRegenerationCandidate),
byItemID: make(map[int64]currentEquipmentRegenerationCandidate),
}
for _, section := range configDocument.Sections {
key := currentRandomOptionSectionKey(section.Name)
switch key {
case "choose part":
values := currentRandomOptionSectionValues(configDocument, section)
if len(values) < 3 {
continue
}
partValue, ok := values[0].integer()
if !ok || partValue <= 0 || partValue > math.MaxUint16 {
continue
}
groups := make(map[string]bool)
for _, value := range values[2:] {
if group := normalizeEquipmentRegenerationToken(value.text); group != "" {
groups[group] = true
}
}
if len(groups) > 0 {
catalog.parts[uint16(partValue)] = groups
}
case "except item index":
for _, itemID := range currentRandomOptionSectionInts(configDocument, section) {
if itemID > 0 {
catalog.excluded[itemID] = true
}
}
case "probability exception handling":
values := currentRandomOptionSectionNumbers(configDocument, section)
for index := 0; index+1 < len(values); index += 2 {
itemID := int64(values[index].value)
weight := int64(math.Round(values[index+1].value * 1000))
if itemID > 0 && weight > 0 {
catalog.exceptionWeights[itemID] = weight
}
}
case "compound need material for specific":
catalog.materials = parseCurrentEquipmentRegenerationMaterials(configDocument, section)
case "compound specific apply cost by level":
catalog.costs = parseCurrentEquipmentRegenerationCosts(configDocument, section)
}
}
for _, section := range overallDocument.Sections {
if currentRandomOptionSectionKey(section.Name) != "regen level limit" {
continue
}
values := currentRandomOptionSectionInts(overallDocument, section)
for index := 0; index+3 < len(values); index += 4 {
group, level := values[index], values[index+1]
if group <= 0 || level < 0 || values[index+2] < 0 || values[index+3] < 0 {
continue
}
if catalog.levelWeights[group] == nil {
catalog.levelWeights[group] = make(map[int64]currentEquipmentRegenerationLevelWeight)
}
catalog.levelWeights[group][level] = currentEquipmentRegenerationLevelWeight{current: values[index+2], plusFive: values[index+3]}
}
}
if len(catalog.parts) == 0 || len(catalog.materials) == 0 || len(catalog.costs) == 0 {
return nil, fmt.Errorf("equipment regeneration PVF config incomplete: parts=%d materials=%d costs=%d", len(catalog.parts), len(catalog.materials), len(catalog.costs))
}
ids := make([]int, 0, len(items.itemRefs))
for itemID, reference := range items.itemRefs {
if reference.kind == dungeonDropItemEquipment {
ids = append(ids, int(itemID))
}
}
sort.Ints(ids)
for _, rawID := range ids {
itemID := uint32(rawID)
reference := items.itemRefs[itemID]
docPath, text, readErr := readDungeonDropText(items.source, string(dungeonDropItemEquipment), reference.path)
if readErr != nil {
continue
}
document, parseErr := dnfpvf.Parse(docPath, text)
if parseErr != nil {
continue
}
rarity, rarityOK := document.Int("rarity")
minimumLevel, levelOK := document.Int("minimum level")
itemGroup, groupOK := document.Text("item group name")
attachType, attachOK := document.Text("attach type")
equipmentType, typeOK := document.Text("equipment type")
if !rarityOK || !levelOK || !groupOK || !attachOK || !typeOK || rarity < 0 || minimumLevel < 0 {
continue
}
creationRate, _ := document.Int("creation rate")
itemCategory, _ := document.Text("item category")
legacy := normalizeEquipmentRegenerationToken(itemCategory) == "legacy"
forceRule := document.Ints("force result item rule")
if rarity == 3 && creationRate == 0 && len(forceRule) >= 2 && forceRule[0] == 1 && forceRule[1] == 0 {
continue
}
if catalog.excluded[int64(itemID)] {
continue
}
if !equipmentRegenerationEligible(rarity, minimumLevel, legacy, creationRate, catalog.exceptionWeights[int64(itemID)] > 0) {
continue
}
weight := catalog.exceptionWeights[int64(itemID)]
if weight == 0 {
weight = 1000
}
durabilityValue, _ := document.Int("durability")
if durabilityValue < 0 {
durabilityValue = 0
}
if durabilityValue > math.MaxUint16 {
durabilityValue = math.MaxUint16
}
candidate := currentEquipmentRegenerationCandidate{
itemID: int64(itemID), pvfPath: docPath, equipmentType: equipmentType,
itemGroup: normalizeEquipmentRegenerationToken(itemGroup), attachType: normalizeEquipmentRegenerationToken(attachType),
itemCategory: normalizeEquipmentRegenerationToken(itemCategory), rarity: rarity, minimumLevel: minimumLevel,
creationRate: creationRate, durability: uint16(durabilityValue), weight: weight, legacy: legacy,
}
catalog.byItemID[candidate.itemID] = candidate
catalog.candidates[[2]int64{rarity, minimumLevel}] = append(catalog.candidates[[2]int64{rarity, minimumLevel}], candidate)
}
if len(catalog.byItemID) == 0 {
return nil, fmt.Errorf("equipment regeneration candidate catalog empty")
}
return catalog, nil
}
func parseCurrentEquipmentRegenerationDocument(source dnfpvf.Source, documentPath string) (*dnfpvf.Document, error) {
text, err := source.ReadText(documentPath)
if err != nil {
return nil, fmt.Errorf("read equipment regeneration PVF %s: %w", documentPath, err)
}
document, err := dnfpvf.Parse(documentPath, text)
if err != nil {
return nil, fmt.Errorf("parse equipment regeneration PVF %s: %w", documentPath, err)
}
return document, nil
}
func parseCurrentEquipmentRegenerationMaterials(document *dnfpvf.Document, section dnfpvf.Section) map[int64][]currentEquipmentRegenerationMaterial {
values := currentRandomOptionSectionInts(document, section)
result := make(map[int64][]currentEquipmentRegenerationMaterial)
for index := 0; index+1 < len(values); {
group, count := values[index], values[index+1]
index += 2
if group <= 0 || count <= 0 || count > 16 || index+int(count)*2 > len(values) {
break
}
rows := make([]currentEquipmentRegenerationMaterial, 0, count)
for row := int64(0); row < count; row++ {
itemID, amount := values[index], values[index+1]
index += 2
if itemID > 0 && amount > 0 {
rows = append(rows, currentEquipmentRegenerationMaterial{itemID: itemID, count: amount})
}
}
if len(rows) > 0 {
result[group] = rows
}
}
return result
}
func parseCurrentEquipmentRegenerationCosts(document *dnfpvf.Document, section dnfpvf.Section) map[int64]map[int64]int64 {
values := currentRandomOptionSectionNumbers(document, section)
result := make(map[int64]map[int64]int64)
for index := 0; index+2 < len(values); index += 3 {
group := int64(values[index].value)
level := int64(values[index+1].value)
multiplier := int64(math.Round(values[index+2].value))
if group <= 0 || level < 0 || multiplier <= 0 {
continue
}
if result[group] == nil {
result[group] = make(map[int64]int64)
}
result[group][level] = multiplier
}
return result
}
func (c *currentEquipmentRegenerationCatalog) resolve(sourceItemID int64, mode uint16, part uint16) (alignedcmd.EquipmentRegenerationResolution, error) {
resolution := alignedcmd.EquipmentRegenerationResolution{SourceItemID: sourceItemID}
if c == nil || sourceItemID <= 0 || mode != 0 || part == 0 {
return resolution, nil
}
source, found := c.byItemID[sourceItemID]
if !found || c.excluded[sourceItemID] || source.attachType != "sealing" || !c.parts[part][source.itemGroup] {
return resolution, nil
}
group := source.rarity
if source.legacy {
group = 99
}
baseMaterials := c.materials[group]
multiplier := floorEquipmentRegenerationValue(c.costs[group], source.minimumLevel)
if len(baseMaterials) == 0 || multiplier <= 0 {
return resolution, nil
}
materials := make([]alignedcmd.EquipmentRegenerationMaterial, 0, len(baseMaterials))
for _, material := range baseMaterials {
count := material.count * multiplier
if count <= 0 {
return resolution, nil
}
materials = append(materials, alignedcmd.EquipmentRegenerationMaterial{
ItemID: material.itemID, Count: count, AccountShared: equipmentRegenerationAccountSharedItem(material.itemID),
})
}
levelRule := floorEquipmentRegenerationLevelWeight(c.levelWeights[group], source.minimumLevel)
type weightedPool struct {
weight int64
candidates []currentEquipmentRegenerationCandidate
}
levels := []struct{ level, weight int64 }{{source.minimumLevel, levelRule.current}}
if source.minimumLevel < 85 {
levels = append(levels, struct{ level, weight int64 }{source.minimumLevel + 5, levelRule.plusFive})
} else if levelRule.plusFive > 0 {
levels = append(levels, struct{ level, weight int64 }{source.minimumLevel + 5, levelRule.plusFive})
}
if levelRule.current == 0 && levelRule.plusFive == 0 {
levels = []struct{ level, weight int64 }{{source.minimumLevel, 1}}
}
pools := make([]weightedPool, 0, len(levels))
for _, level := range levels {
if level.weight <= 0 {
continue
}
pool := weightedPool{weight: level.weight}
for _, candidate := range c.candidates[[2]int64{source.rarity, level.level}] {
// The output pool is every group the requested `[choose part]`
// lists, not just the source's own `[item group name]`. Part 1
// (武器) alone spans 37 weapon families; narrowing to the source
// family collapsed the pool to a single candidate at every tier
// where each family has one `[sealing]` piece per level -- which
// is the whole 90 level band -- so regeneration always handed the
// player back the exact item they fed in.
if candidate.attachType != "sealing" || !c.parts[part][candidate.itemGroup] ||
(source.legacy && !candidate.legacy) || (!source.legacy && candidate.legacy) {
continue
}
pool.candidates = append(pool.candidates, candidate)
}
// A pool whose only member is the source is a guaranteed no-op: the
// player pays the soul cost and gets the exact piece back. Drop it so
// the attempt is refused up front instead of charged. Against the
// active PVF this leaves out exactly one band -- 传承 rarity-2 weapons
// at [minimum level] 9, where item 33947 (传承 : 神秘的银湖水仙之法杖)
// is the only [sealing] piece in the game and its +5 band is empty.
// Every other band has at least three candidates.
if len(pool.candidates) == 1 && pool.candidates[0].itemID == sourceItemID {
continue
}
if len(pool.candidates) > 0 {
pools = append(pools, pool)
}
}
if len(pools) == 0 {
return resolution, nil
}
poolLevelWeights := make([]int64, len(pools))
for index := range pools {
poolLevelWeights[index] = pools[index].weight
}
poolIndex, err := secureEquipmentRegenerationChoice(poolLevelWeights)
if err != nil {
return resolution, err
}
pool := pools[poolIndex]
weights := make([]int64, len(pool.candidates))
for index := range pool.candidates {
weights[index] = pool.candidates[index].weight
}
candidateIndex, err := secureEquipmentRegenerationChoice(weights)
if err != nil {
return resolution, err
}
selected := pool.candidates[candidateIndex]
qualitySeed, err := newCurrentEquipmentQualitySeed()
if err != nil {
return resolution, err
}
return alignedcmd.EquipmentRegenerationResolution{
Eligible: true, SourceItemID: sourceItemID, SourcePVFPath: source.pvfPath,
ResultItemID: selected.itemID, ResultPVFPath: selected.pvfPath, ResultEquipmentType: selected.equipmentType,
ResultRarity: selected.rarity, ResultMinimumLevel: selected.minimumLevel, ResultDurability: selected.durability,
QualitySeed: qualitySeed, Materials: materials, CandidateCount: len(pool.candidates),
}, nil
}
func secureEquipmentRegenerationChoice(weights []int64) (int, error) {
total := int64(0)
for _, weight := range weights {
if weight <= 0 || weight > math.MaxInt64-total {
continue
}
total += weight
}
if total <= 0 {
return 0, errors.New("equipment regeneration candidate weights empty")
}
rollBig, err := cryptorand.Int(cryptorand.Reader, big.NewInt(total))
if err != nil {
return 0, err
}
roll := rollBig.Int64()
for index, weight := range weights {
if weight <= 0 {
continue
}
if roll < weight {
return index, nil
}
roll -= weight
}
return 0, errors.New("equipment regeneration candidate selection exhausted")
}
func floorEquipmentRegenerationValue(rows map[int64]int64, level int64) int64 {
bestLevel, value := int64(-1), int64(0)
for candidateLevel, candidateValue := range rows {
if candidateLevel <= level && candidateLevel >= bestLevel {
bestLevel, value = candidateLevel, candidateValue
}
}
return value
}
func floorEquipmentRegenerationLevelWeight(rows map[int64]currentEquipmentRegenerationLevelWeight, level int64) currentEquipmentRegenerationLevelWeight {
bestLevel := int64(-1)
var value currentEquipmentRegenerationLevelWeight
for candidateLevel, candidateValue := range rows {
if candidateLevel <= level && candidateLevel >= bestLevel {
bestLevel, value = candidateLevel, candidateValue
}
}
return value
}
func equipmentRegenerationEligible(rarity, minimumLevel int64, legacy bool, creationRate int64, exception bool) bool {
return (minimumLevel >= 85 && rarity == 6) || legacy || exception || creationRate > 0
}
// equipmentRegenerationAccountSharedItem 判断该道具是否住在账号共享晶体/灵魂仓库。
// 这里过去是一份手写的 12 条 ID 副本,现统一走权威表
// (repository/account_shared_materials.go),避免与另两处副本漂移。
func equipmentRegenerationAccountSharedItem(itemID int64) bool {
return dnfrepo.IsAccountSharedWarehouseItem(itemID)
}
func normalizeEquipmentRegenerationToken(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
return strings.Trim(value, "`[](){}<> \t\r\n")
}