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,350 @@
package dnfbridge
import (
"encoding/binary"
"errors"
"fmt"
"math"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
)
const (
currentBoosterSelectionBodySize = 10
currentBoosterRandomBodySize = 2
currentBoosterMultiBodyBaseSize = 6
currentBoosterMultiEntrySize = 9
// currentBoosterCompactBodyBaseSize / currentBoosterCompactEntrySize 描述当前客户端对
// "选择型礼盒、候选行是 etc/stackable(没有逐项 option)"时用的紧凑写法:
// u16 槽位 + u16 类别 + N×u32 选中道具 + u8 选项 + u8 尾字节 = 4 + 4N + 2
//
// 现场取证(2026-09-21 20:39,角色 1111,runtime/logs/packet_log.txt):2018夏日礼包
// 重购惊喜礼盒 4(490702367,PVF `[booster selection num] 2`、`[booster select
// category] 0 0`)被点了两次,客户端两次发出**完全相同的 14 字节**:
// 72 00 | 00 00 | f7 85 3f 1d | 64 86 3f 1d | 00 00
// = 槽位 114、类别 0、道具 490702327 与 490702436、选项/尾字节 0
// 这两个道具号正是该礼盒 PVF 类别 0 里的 `[etc] 490702327 1` 与
// `[stackable] 490702436 1 490702437 1 490702438 1 490702439 1`,件数恰好等于
// `[booster selection num] 2`。旧解析器只认 2 / 10 / 6+9n,长度一票否决,于是客户端
// 把失败码 4 渲染成「物品栏空间不足」——与背包无关(同 2026-09-15 公会礼盒那次的坑,
// 见 changlog.md)。
currentBoosterCompactBodyBaseSize = 6
currentBoosterCompactEntrySize = 4
currentBoosterMaxRewardRows = 128
currentBoosterMaxRewardUnits = 1_000_000
currentBoosterPetArtifactStart = int16(140)
currentBoosterPetArtifactEnd = int16(188)
)
var (
errCurrentBoosterRequestMalformed = errors.New("dnf booster request is malformed")
errCurrentBoosterOwnerUnavailable = errors.New("dnf booster selected character is unavailable")
errCurrentBoosterSourceMissing = errors.New("dnf booster source item is unavailable")
errCurrentBoosterPVFInvalid = errors.New("dnf booster runtime PVF data is invalid")
errCurrentBoosterSelectionInvalid = errors.New("dnf booster selection is invalid")
errCurrentBoosterExpired = errors.New("dnf booster source item has expired")
// errCurrentBoosterCooltimeActive:PVF `[cooltime maintenance]` 的道具仍在冷却窗口内。
// 客户端的冷却门只是进程内内存,小退重上就没了,所以这条判定必须由服务端兜住
// (现场 2026-09-17:1 天冷却的礼盒重上后又能用)。
errCurrentBoosterCooltimeActive = errors.New("dnf booster source item is still cooling down")
// errCurrentBoosterDailyUseLimitReached:PVF `[total usable count]`(客户端悬浮
// 说明里的「每天可用次数」)今天已经用满。上限属于道具本身、与它走哪条消费路径
// 无关,礼盒也是消耗品,所以开箱这条路径用与 op44/op515 完全相同的那份计数
// (internal/modules/dnf/itemdailylimit),并且判定发生在**扣道具的同一个事务**里:
// 到上限时整笔回滚,源礼盒不会被消耗。
errCurrentBoosterDailyUseLimitReached = errors.New("dnf booster source item reached its daily usable count")
)
type currentBoosterRequestKind byte
const (
currentBoosterRequestRandom currentBoosterRequestKind = iota
currentBoosterRequestSelection
// currentBoosterRequestLottery is the native op27 jar route. It deliberately
// stays distinct from op160's immediate random-box route because the current
// client consumes a complete display item row before playing the jar
// animation; any later historical op217 callback is idempotent.
currentBoosterRequestLottery
)
// currentBoosterSelectionRequest is one PVF-backed item/attribute pair written
// by the current NoPack op160 selection UI.
type currentBoosterSelectionRequest struct {
ItemID uint32
Option byte
}
// currentBoosterOpenRequest follows the current NoPack op160 writers seen on
// the live connection. Plain [booster] sends only u16 sourceSlot. A one-choice
// [booster selection] sends u16 slot, u16 category context, u32 selected item,
// u8 option and one trailing UI byte. A selection_num=0 category sends every
// category item twice: first N*u32 item IDs, then u8 N and N*{u32 item,u8
// option}, followed by one trailing UI byte.
type currentBoosterOpenRequest struct {
Kind currentBoosterRequestKind
SourceSlot int16
SelectionContext uint16
Selections []currentBoosterSelectionRequest
SelectedItemID uint32
SelectionOption byte
TrailingUIByte byte
RewardMultiplier uint32
ConsumePremiumDaily bool
PremiumDailySlot int64
}
type currentBoosterSelectionCandidate struct {
ItemID uint32
Count uint32
CategoryKind int64
Option byte
// UpgradeLevel 来自本分类的 [booster equipment upgrade],只对装备行生效;
// 0 表示"不额外强化"。升级预约(活动 222)发放的武器靠它决定 +N。
UpgradeLevel byte
}
type currentBoosterRewardPlacement byte
const (
currentBoosterRewardMain currentBoosterRewardPlacement = iota
currentBoosterRewardAvatar
currentBoosterRewardPetBody
currentBoosterRewardPetArtifact
currentBoosterRewardPetConsumable
)
type currentBoosterReward struct {
Definition dungeonDropItemDefinition
Count uint32
Option byte
Seal bool
Placement currentBoosterRewardPlacement
// UpgradeLevel 是选中分类的 [booster equipment upgrade] 带来的强化等级,
// 由选中判定回填;仅对主背包装备生效。
UpgradeLevel byte
}
type currentBoosterGrantedReward struct {
ItemID uint32
Count uint32
}
type currentBoosterDefinition struct {
Source dungeonDropItemDefinition
StackableType string
SelectionRequired int
Selection []currentBoosterSelectionCandidate
SelectionCategory map[uint16][]currentBoosterSelectionCandidate
MaterialItemID int64
MaterialCount int64
Random alignedcmd.MagicBoxResolution
}
type currentBoosterCommitResult struct {
SourceItemID uint32
SourceSlot int16
SourceRemaining uint32
// SourceCooltimeUntil 是这次开启落下的冷却到期时间(unix 秒),0 = 该道具没有
// [cooltime maintenance],不参与持久冷却。
SourceCooltimeUntil int64
// SourceDailyUsed / SourceDailyLimit 是这次开启之后的「每天可用次数」进度
// (PVF `[total usable count]`)。Limit<=0 表示该道具没有声明上限,本次既没读也
// 没写计数,也不需要发 op541。
SourceDailyUsed int64
SourceDailyLimit int64
GoldGranted uint32
GoldAfter int64
Rewards []currentBoosterGrantedReward
ChangedLists []byte
RewardSlots []alignedcmd.ItemSlotRefresh
EpicBookRows []currentEpicBookBalanceRow
PremiumActivations []currentCeraShopPremiumActivation
ReviveCoinWalletChanged bool
// VoucherCredits 是本次开启里直接入账号钱包的代币券 / 欢乐代币券数量。
// 券在 PVF 里是普通 `[material]`,客户端没有任何把它变成钱包余额的途径,
// 所以服务端在发放时就入账、不占背包格(见 cerashop/voucher.go)。
VoucherCredits dnfcerashop.VoucherCounts
LotteryDisplayEntry currentItemListEntry
LotteryDisplayEntrySet bool
}
func parseCurrentBoosterOpenRequest(body []byte) (currentBoosterOpenRequest, error) {
switch len(body) {
case currentBoosterRandomBodySize:
return currentBoosterOpenRequest{
Kind: currentBoosterRequestRandom,
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
}, nil
case currentBoosterSelectionBodySize:
selectedItemID := binary.LittleEndian.Uint32(body[4:8])
if selectedItemID == 0 {
return currentBoosterOpenRequest{}, errCurrentBoosterRequestMalformed
}
return currentBoosterOpenRequest{
Kind: currentBoosterRequestSelection,
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
SelectionContext: binary.LittleEndian.Uint16(body[2:4]),
Selections: []currentBoosterSelectionRequest{{
ItemID: selectedItemID,
Option: body[8],
}},
SelectedItemID: selectedItemID,
SelectionOption: body[8],
TrailingUIByte: body[9],
}, nil
}
// 6+9n 必须排在紧凑体之前:n 是 4 的倍数时(n=4/8/…,长度 42/78/…)两条长度判据会同时
// 成立,而那几档长度在现网确实由"逐项带 option"的多选写法发出(booster_item_test.go 里
// 的八件套真实包体),不能改判成紧凑体。
if currentBoosterHasMultiSelectionShape(len(body)) {
return parseCurrentBoosterMultiSelectionRequest(body)
}
if count, ok := currentBoosterCompactSelectionCount(len(body)); ok {
return parseCurrentBoosterCompactSelectionRequest(body, count)
}
return currentBoosterOpenRequest{}, fmt.Errorf(
"%w: body_len=%d want=2_10_6_plus_9n_or_4_plus_4n_plus_2",
errCurrentBoosterRequestMalformed,
len(body),
)
}
// currentBoosterHasMultiSelectionShape 就是"逐项带 option 的多选写法"(6+9n)的长度判据。
// 抽成函数是为了让分发器和解析器永远给出同一个答案:这两处一旦不一致,就会出现
// "分发器把它当紧凑体、解析器又把它当多选体"的错位。
func currentBoosterHasMultiSelectionShape(length int) bool {
return length > currentBoosterSelectionBodySize &&
length >= currentBoosterMultiBodyBaseSize+currentBoosterMultiEntrySize &&
(length-currentBoosterMultiBodyBaseSize)%currentBoosterMultiEntrySize == 0
}
// currentBoosterCompactSelectionCount 报告紧凑选择体的选中件数 N(长度 = 6 + 4N,N>=1)。
// 长度 10(N=1)由 parseCurrentBoosterOpenRequest 的 case 分支原样处理(语义与这里等价),
// 所以走到这里的只可能是 N>=2。
func currentBoosterCompactSelectionCount(length int) (int, bool) {
remainder := length - currentBoosterCompactBodyBaseSize
if length <= currentBoosterSelectionBodySize ||
remainder <= 0 ||
remainder%currentBoosterCompactEntrySize != 0 {
return 0, false
}
count := remainder / currentBoosterCompactEntrySize
if count < 1 || count > currentBoosterMaxRewardRows || count > math.MaxUint8 {
return 0, false
}
return count, true
}
// parseCurrentBoosterCompactSelectionRequest 解析 4 + 4N + 2 的紧凑选择体。
//
// 选项与尾字节整条请求共用一份:现网这一形态的礼盒,候选行都是 etc/stackable(PVF 里
// 每行只有 item/count,没有 option),需要逐项 option 的装扮类选择礼盒走 6+9n
// (八件套真实包体)。客户端给的任何数量都不作数:每件的数量由 PVF 候选行决定,件数由
// `[booster selection num]` 在 currentBoosterSelectedCandidates 里卡死,道具号必须出现在
// 该礼盒该类别里 —— 与 10 字节单选走的是同一条校验。
func parseCurrentBoosterCompactSelectionRequest(body []byte, count int) (currentBoosterOpenRequest, error) {
option := body[len(body)-2]
selections := make([]currentBoosterSelectionRequest, 0, count)
seen := make(map[uint32]struct{}, count)
for index := 0; index < count; index++ {
offset := 4 + index*currentBoosterCompactEntrySize
itemID := binary.LittleEndian.Uint32(body[offset : offset+4])
if itemID == 0 {
return currentBoosterOpenRequest{}, fmt.Errorf(
"%w: selection_index=%d item=0",
errCurrentBoosterRequestMalformed,
index,
)
}
if _, duplicate := seen[itemID]; duplicate {
return currentBoosterOpenRequest{}, fmt.Errorf("%w: duplicate_item=%d", errCurrentBoosterRequestMalformed, itemID)
}
seen[itemID] = struct{}{}
selections = append(selections, currentBoosterSelectionRequest{ItemID: itemID, Option: option})
}
return currentBoosterOpenRequest{
Kind: currentBoosterRequestSelection,
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
SelectionContext: binary.LittleEndian.Uint16(body[2:4]),
Selections: selections,
SelectedItemID: selections[0].ItemID,
SelectionOption: selections[0].Option,
TrailingUIByte: body[len(body)-1],
}, nil
}
func parseCurrentBoosterMultiSelectionRequest(body []byte) (currentBoosterOpenRequest, error) {
if !currentBoosterHasMultiSelectionShape(len(body)) {
return currentBoosterOpenRequest{}, fmt.Errorf(
"%w: body_len=%d want=2_10_6_plus_9n_or_4_plus_4n_plus_2",
errCurrentBoosterRequestMalformed,
len(body),
)
}
selectionCount := (len(body) - currentBoosterMultiBodyBaseSize) / currentBoosterMultiEntrySize
if selectionCount < 1 || selectionCount > currentBoosterMaxRewardRows || selectionCount > math.MaxUint8 {
return currentBoosterOpenRequest{}, fmt.Errorf("%w: selection_count=%d", errCurrentBoosterRequestMalformed, selectionCount)
}
countOffset := 4 + selectionCount*4
if int(body[countOffset]) != selectionCount {
return currentBoosterOpenRequest{}, fmt.Errorf(
"%w: selection_count_byte=%d want=%d",
errCurrentBoosterRequestMalformed,
body[countOffset],
selectionCount,
)
}
recordOffset := countOffset + 1
selections := make([]currentBoosterSelectionRequest, 0, selectionCount)
seen := make(map[uint32]struct{}, selectionCount)
for index := 0; index < selectionCount; index++ {
prefixItemID := binary.LittleEndian.Uint32(body[4+index*4 : 8+index*4])
entryOffset := recordOffset + index*5
recordItemID := binary.LittleEndian.Uint32(body[entryOffset : entryOffset+4])
if prefixItemID == 0 || recordItemID != prefixItemID {
return currentBoosterOpenRequest{}, fmt.Errorf(
"%w: selection_index=%d prefix_item=%d record_item=%d",
errCurrentBoosterRequestMalformed,
index,
prefixItemID,
recordItemID,
)
}
if _, duplicate := seen[recordItemID]; duplicate {
return currentBoosterOpenRequest{}, fmt.Errorf("%w: duplicate_item=%d", errCurrentBoosterRequestMalformed, recordItemID)
}
seen[recordItemID] = struct{}{}
selections = append(selections, currentBoosterSelectionRequest{
ItemID: recordItemID,
Option: body[entryOffset+4],
})
}
return currentBoosterOpenRequest{
Kind: currentBoosterRequestSelection,
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
SelectionContext: binary.LittleEndian.Uint16(body[2:4]),
Selections: selections,
SelectedItemID: selections[0].ItemID,
SelectionOption: selections[0].Option,
TrailingUIByte: body[len(body)-1],
}, nil
}
// buildCurrentBoosterSuccessBody matches current NoPack sub_1D2BFF0 after
// the shared success byte: u32 source item, u16 source slot, u32 remaining,
// u32 special-result mode (zero for ordinary boxes), u16 reward count, then
// rewardCount * {u32 item, u32 display count}.
func buildCurrentBoosterSuccessBody(result currentBoosterCommitResult) []byte {
var writer packetWriter
writer.writeUint32(result.SourceItemID)
writer.writeUint16(uint16(result.SourceSlot))
writer.writeUint32(result.SourceRemaining)
writer.writeUint32(0)
writer.writeUint16(uint16(len(result.Rewards)))
for _, reward := range result.Rewards {
writer.writeUint32(reward.ItemID)
writer.writeUint32(reward.Count)
}
return writer.bytes()
}