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,463 @@
package dnfbridge
import (
"errors"
"fmt"
"math"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// alignedMagicBoxResolversForCommand keeps PVF loading request-driven. Only
// the single-open magic-box command needs these dependencies; other aligned
// commands do not open or scan the item catalog. The resolvers mirror the
// 86JP StackableItemFile contract: [RANDOMBOX] [int data] groups (skip the
// leading triple, then (item, weight, count, ignored) quads), the
// [booster info] child-tag groups (optional leading DrawCount, then ordinary
// triples or historical avatar five-field rows),
// and the [sealing removal item] / [need material] material requirement.
func (s *Service) alignedMagicBoxResolversForCommand(opcode dnfenum.CmdPacket) (alignedcmd.MagicBoxResolver, alignedcmd.MagicBoxRewardItemResolver, error) {
if opcode != dnfenum.CmdPacketUseRandomboxItem && opcode != dnfenum.CmdPacketUseRandomboxItemExpand {
return nil, nil, nil
}
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return nil, nil, err
}
if catalog == nil {
return nil, nil, errDungeonDropSourceRequired
}
s.initialEquipmentMu.Lock()
source, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return nil, nil, err
}
// Rewards the runtime PVF defines as premium contracts never take a bag cell:
// the bridge extends the account entitlement through the activator instead.
contractItems := make(map[int64]struct{})
if premiumCatalog, premiumErr := s.currentPremiumCatalog(); premiumErr == nil && premiumCatalog != nil {
for itemID := range premiumCatalog.contractsByItem {
contractItems[itemID] = struct{}{}
}
}
return func(boxItemID int64) (alignedcmd.MagicBoxResolution, error) {
return resolveCurrentMagicBox(catalog, source, boxItemID)
}, func(itemID int64) (alignedcmd.MagicBoxRewardItem, error) {
reward, err := resolveCurrentMagicBoxRewardItem(catalog, source, itemID)
if err != nil {
return reward, err
}
if _, isContract := contractItems[itemID]; isContract {
reward.DirectActivation = true
}
return reward, nil
}, nil
}
// resolveCurrentMagicBox resolves one box template through the active runtime
// PVF. Unresolvable or non-stackable templates and unsupported box families
// resolve with an empty Kind; only catalog/document read failures surface as
// errors so the open fails closed.
func resolveCurrentMagicBox(catalog *pvfDungeonDropCatalog, source dnfpvf.Source, boxItemID int64) (alignedcmd.MagicBoxResolution, error) {
resolution := alignedcmd.MagicBoxResolution{}
if catalog == nil || source == nil {
return resolution, errDungeonDropSourceRequired
}
if boxItemID <= 0 || boxItemID > int64(^uint32(0)) {
return resolution, nil
}
definition, err := catalog.ResolveItem(uint32(boxItemID))
if err != nil {
if errors.Is(err, errDungeonDropItemUnresolved) {
return resolution, nil
}
return alignedcmd.MagicBoxResolution{}, fmt.Errorf("resolve magic box item=%d: %w", boxItemID, err)
}
if definition.Kind != dungeonDropItemStackable {
return resolution, nil
}
document, err := parseDungeonCardPVFDocument(source, definition.PVFPath)
if err != nil {
return alignedcmd.MagicBoxResolution{}, fmt.Errorf("parse magic box item=%d path=%s: %w", boxItemID, definition.PVFPath, err)
}
resolution.BoxPVFPath = definition.PVFPath
switch normalizeMagicBoxPVFType(magicBoxDocumentText(document, "stackable type")) {
case "upgradable legacy":
resolution.Groups = parseMagicBoxUpgradableLegacyGroups(document)
case "random upgradable legacy":
resolution.Groups = parseMagicBoxRandomGroups(document)
case "booster", "cera booster", "booster random":
resolution.Groups = parseMagicBoxBoosterGroups(document)
default:
return resolution, nil
}
resolution.Groups = filterCurrentMagicBoxResolvableRewardGroups(catalog, resolution.Groups)
if len(resolution.Groups) == 0 {
return alignedcmd.MagicBoxResolution{}, nil
}
resolution.Kind = "random"
resolution.MaterialItemID, resolution.MaterialCountPerUse = resolveMagicBoxNeedMaterial(document, boxItemID)
return resolution, nil
}
// filterCurrentMagicBoxResolvableRewardGroups removes stale PVF rows whose
// item template is absent from the active equipment/stackable indexes. Some
// official legacy jars retain rows for character equipment that was removed
// from the same Script.pvf. Keeping those rows in the weighted pool makes a
// valid jar fail only when the stale row happens to win. Gold pseudo rows are
// retained because list-0 slot 0 is a wallet row rather than an item template.
func filterCurrentMagicBoxResolvableRewardGroups(
catalog *pvfDungeonDropCatalog,
groups []alignedcmd.MagicBoxRewardGroup,
) []alignedcmd.MagicBoxRewardGroup {
filtered := make([]alignedcmd.MagicBoxRewardGroup, 0, len(groups))
for _, group := range groups {
entries := make([]alignedcmd.MagicBoxRewardEntry, 0, len(group.Entries))
for _, entry := range group.Entries {
if entry.Gold && entry.ItemID == 0 {
entries = append(entries, entry)
continue
}
if catalog == nil || entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 {
continue
}
if _, err := catalog.ResolveItem(uint32(entry.ItemID)); err != nil {
continue
}
entries = append(entries, entry)
}
if len(entries) == 0 {
continue
}
group.Entries = entries
filtered = append(filtered, group)
}
return filtered
}
// parseMagicBoxUpgradableLegacyGroups reads the older top-level [int data]
// pool used by historical event weapon/gift boxes. Current NoPack opens this
// family through the native two-phase op27 lottery flow; each row is exactly
// (item_id, weight, count). Zero-weight sentinel rows are ignored and each
// int-data section remains one independent weighted group.
func parseMagicBoxUpgradableLegacyGroups(document *dnfpvf.Document) []alignedcmd.MagicBoxRewardGroup {
if document == nil {
return nil
}
groups := make([]alignedcmd.MagicBoxRewardGroup, 0, 2)
for _, section := range document.Sections {
if normalizeMagicBoxSectionName(section.Name) != "int data" {
continue
}
ints := magicBoxSectionInts(document, section)
if len(ints) == 0 || len(ints)%3 != 0 {
continue
}
group := alignedcmd.MagicBoxRewardGroup{DrawCount: 1}
for index := 0; index+2 < len(ints); index += 3 {
itemID, weight, count := ints[index], ints[index+1], ints[index+2]
if itemID < 0 || weight <= 0 || count <= 0 {
continue
}
group.Entries = append(group.Entries, alignedcmd.MagicBoxRewardEntry{
ItemID: itemID,
Weight: weight,
Count: count,
Gold: itemID == 0,
})
}
if len(group.Entries) > 0 {
groups = append(groups, group)
}
}
return groups
}
// parseMagicBoxRandomGroups walks the [RANDOMBOX] child [int data] sections.
// Each section is one independent group with DrawCount 1; per the 86JP
// ParseRandomBoxRewards rule the leading triple is skipped and every later
// four ints form one (item, weight, count, ignored) entry. A section with no
// quad entries and at least two ints degrades to one default-weight entry.
func parseMagicBoxRandomGroups(document *dnfpvf.Document) []alignedcmd.MagicBoxRewardGroup {
groups := make([]alignedcmd.MagicBoxRewardGroup, 0, 4)
inRandombox := false
for _, section := range document.Sections {
name := normalizeMagicBoxSectionName(section.Name)
switch name {
case "randombox":
inRandombox = true
continue
case "/randombox":
inRandombox = false
}
if !inRandombox || name != "int data" {
continue
}
ints := magicBoxSectionInts(document, section)
group := alignedcmd.MagicBoxRewardGroup{DrawCount: 1}
if len(ints) >= 7 {
for index := 3; index+3 < len(ints); index += 4 {
if ints[index] <= 0 {
continue
}
group.Entries = append(group.Entries, alignedcmd.MagicBoxRewardEntry{
ItemID: ints[index],
Weight: maxMagicBoxInt(ints[index+1], 0),
Count: maxMagicBoxInt(ints[index+2], 1),
})
}
}
if len(group.Entries) == 0 && len(ints) >= 2 && ints[0] > 0 {
group.Entries = append(group.Entries, alignedcmd.MagicBoxRewardEntry{
ItemID: ints[0],
Weight: 10000,
Count: maxMagicBoxInt(ints[1], 1),
})
}
if len(group.Entries) > 0 {
groups = append(groups, group)
}
}
return groups
}
// parseMagicBoxBoosterGroups walks the [booster info] child tags. Each direct
// child tag is one independent group. Ordinary rows are (item, weight, count)
// triples. Historical [avatar] rows contain five fields and carry the avatar
// option in the fifth field; the fourth field is retained only as PVF shape.
//
// `[charactor]` is the job-gated form: its first token is a job tag (e.g.
// `[swordman]`), then minLevel/maxLevel/drawCount, then the same triples.
// It only participates for the matching job — see MagicBoxGroupsForCharacter.
func parseMagicBoxBoosterGroups(document *dnfpvf.Document) []alignedcmd.MagicBoxRewardGroup {
groups := make([]alignedcmd.MagicBoxRewardGroup, 0, 4)
inInfo := false
for _, section := range document.Sections {
name := normalizeMagicBoxSectionName(section.Name)
switch name {
case "booster info":
inInfo = true
continue
case "/booster info":
inInfo = false
}
if !inInfo || strings.HasPrefix(name, "/") {
continue
}
ints := magicBoxSectionInts(document, section)
stride := 3
if name == "avatar" {
stride = 5
}
group := alignedcmd.MagicBoxRewardGroup{DrawCount: 1}
start := 0
if name == "charactor" {
// `[charactor]` 段 = `<职业标签> <minLevel> <maxLevel> <drawCount> <item> <weight> <count>...`
// 职业标签是**字符串** token,所以不能只看整数个数,必须核对 token 形状。
tokens := document.Tokens[section.Start:section.End]
if len(tokens) < 7 || tokens[0].Kind != dnfpvf.TokenString ||
strings.TrimSpace(tokens[0].Value) == "" || len(tokens) != len(ints)+1 ||
len(ints) < 6 || (len(ints)-3)%3 != 0 ||
ints[0] < 1 || ints[1] < ints[0] || ints[2] < 1 {
return nil
}
group.Job = tokens[0].Value
group.MinLevel, group.MaxLevel = ints[0], ints[1]
group.DrawCount, start = ints[2], 3
}
if name != "charactor" && len(ints) >= stride+1 && (len(ints)-1)%stride == 0 {
group.DrawCount = maxMagicBoxInt(ints[0], 1)
start = 1
}
if (len(ints)-start)%stride != 0 {
continue
}
for index := start; index+stride-1 < len(ints); index += stride {
if ints[index] <= 0 {
continue
}
entry := alignedcmd.MagicBoxRewardEntry{
ItemID: ints[index],
Weight: maxMagicBoxInt(ints[index+1], 0),
Count: maxMagicBoxInt(ints[index+2], 1),
}
if stride == 5 {
if ints[index+4] < 0 || ints[index+4] > math.MaxUint8 {
continue
}
entry.Option = byte(ints[index+4])
}
group.Entries = append(group.Entries, entry)
}
if len(group.Entries) > 0 {
groups = append(groups, group)
}
}
return groups
}
// resolveMagicBoxNeedMaterial reads the per-open material requirement. The
// [sealing removal item] section is preferred: when its ints are
// 1+header*2 shaped the leading int is an entry count and skipped, the rest
// form (item, count) pairs; the first pair with a positive count that is not
// the box itself wins. Otherwise the top-level [need material] "id count"
// field is used (86JP ResolveNeedMaterial), except for PVF gift boxes. A gift
// box's top-level row is acquisition/exchange metadata and is not an opening
// cost; the owned box must remain unconditionally usable.
func resolveMagicBoxNeedMaterial(document *dnfpvf.Document, boxItemID int64) (int64, int64) {
if itemID, count := resolveMagicBoxSealingRemovalMaterial(document, boxItemID); itemID > 0 && count > 0 {
return itemID, count
}
if currentMagicBoxHasImpossibleContent(document, "gift") {
return 0, 0
}
if values := document.Ints("need material"); len(values) >= 2 && values[0] > 0 && values[1] > 0 {
return values[0], values[1]
}
raw, found := document.Text("need material")
if !found {
return 0, 0
}
var itemID, count int64
if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d %d", &itemID, &count); err != nil || itemID <= 0 || count <= 0 {
return 0, 0
}
return itemID, count
}
// resolveMagicBoxSealingRemovalMaterial returns only the material explicitly
// attached to the opening action. Unlike top-level [need material], this row
// is not an NPC-shop exchange price and must remain enforced for native
// random-upgradable boxes such as the Amazing Box hammer route.
func resolveMagicBoxSealingRemovalMaterial(document *dnfpvf.Document, boxItemID int64) (int64, int64) {
ints := document.Ints("sealing removal item")
if len(ints) > 0 && int64(len(ints)) == 1+ints[0]*2 {
ints = ints[1:]
}
for index := 0; index+1 < len(ints); index += 2 {
itemID, count := ints[index], ints[index+1]
if itemID <= 0 || count <= 0 || itemID == boxItemID {
continue
}
return itemID, count
}
return 0, 0
}
func currentMagicBoxHasImpossibleContent(document *dnfpvf.Document, marker string) bool {
if document == nil {
return false
}
want := normalizeMagicBoxPVFType(marker)
for _, value := range document.Texts("impossible contents") {
if normalizeMagicBoxPVFType(value) == want {
return true
}
}
return false
}
// resolveCurrentMagicBoxRewardItem resolves one reward template for the
// durable grant step: inventory kind, stack limit (0 = unlimited), PVF slot
// range, and the seal flag from its [attach type].
func resolveCurrentMagicBoxRewardItem(catalog *pvfDungeonDropCatalog, source dnfpvf.Source, itemID int64) (alignedcmd.MagicBoxRewardItem, error) {
if catalog == nil || source == nil {
return alignedcmd.MagicBoxRewardItem{}, errDungeonDropSourceRequired
}
if itemID <= 0 || itemID > int64(^uint32(0)) {
return alignedcmd.MagicBoxRewardItem{}, nil
}
definition, err := catalog.ResolveItem(uint32(itemID))
if err != nil {
if errors.Is(err, errDungeonDropItemUnresolved) {
return alignedcmd.MagicBoxRewardItem{}, nil
}
return alignedcmd.MagicBoxRewardItem{}, fmt.Errorf("resolve magic box reward item=%d: %w", itemID, err)
}
definition, err = currentPVFItemDefinitionForGrantAt(definition, time.Now().UTC())
if err != nil {
return alignedcmd.MagicBoxRewardItem{}, fmt.Errorf("resolve magic box reward item=%d expiration: %w", itemID, err)
}
item := alignedcmd.MagicBoxRewardItem{
ItemID: itemID,
Kind: string(definition.Kind),
EquipmentType: definition.EquipmentType,
StackLimit: definition.StackLimit,
SlotStart: definition.SlotStart,
SlotEnd: definition.SlotEnd,
PVFPath: definition.PVFPath,
ExpireAt: definition.ExpirationDate,
UsablePeriodDays: definition.UsablePeriodDays,
}
if isCurrentCeraShopCreatureItem(definition) {
item.TargetListType = currentPetInventoryListType
item.SlotStart = 0
item.SlotEnd = 139
item.StackLimit = 1
} else if isCurrentCeraShopPetConsumable(definition) {
item.TargetListType = currentPetInventoryListType
item.SlotStart = currentCeraShopPetConsumableSlotStart
item.SlotEnd = currentCeraShopPetConsumableSlotEnd
}
document, err := parseDungeonCardPVFDocument(source, definition.PVFPath)
if err != nil {
return alignedcmd.MagicBoxRewardItem{}, fmt.Errorf("parse magic box reward item=%d path=%s: %w", itemID, definition.PVFPath, err)
}
attachType := normalizeMagicBoxPVFType(magicBoxDocumentText(document, "attach type"))
item.Seal = attachType == "sealing"
if durability, found := document.Int("durability"); found && durability >= 0 && durability <= 65535 {
item.Durability = uint16(durability)
}
// 装备奖励的品级在**这里**掷(和掉落掷品级同一层),Owner 侧因此仍然是纯确定性的
// (见 alignedcmd.MagicBoxRewardItem.QualitySeed 的注释)。不掷的话 raw+0x06
// 会落到投影的 Count 兜底上(=1),客户端把每一件奖励装备都画成"最下级"。
// 掷失败不拦发奖:那只是品级显示,不该让玩家拿不到东西。
if definition.Kind == dungeonDropItemEquipment {
if seed, seedErr := newCurrentEquipmentQualitySeed(); seedErr == nil {
item.QualitySeed = seed
}
}
return item, nil
}
func magicBoxDocumentText(document *dnfpvf.Document, name string) string {
values := document.Texts(name)
if len(values) == 0 {
return ""
}
return values[0]
}
func magicBoxSectionInts(document *dnfpvf.Document, section dnfpvf.Section) []int64 {
if section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
return nil
}
out := make([]int64, 0, section.End-section.Start)
for _, token := range document.Tokens[section.Start:section.End] {
if token.Kind == dnfpvf.TokenInt {
out = append(out, token.Int)
}
}
return out
}
func normalizeMagicBoxSectionName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func normalizeMagicBoxPVFType(value string) string {
return strings.ToLower(strings.Trim(value, "` []"))
}
func maxMagicBoxInt(value int64, floor int64) int64 {
if value < floor {
return floor
}
return value
}