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

351 lines
15 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 (
"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()
}