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,306 @@
package dnfbridge
import (
"encoding/binary"
"errors"
"fmt"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentDisjointItemRequestSize = 5
currentDisjointBatchHeaderSize = 14
currentDisjointBatchMaxItems = 127
currentAvatarDisjointRequestMinSize = 2
currentAvatarDisjointRequestFullSize = 6
currentAvatarInventoryListType = 1
currentEmblemCompoundMinInputs = 2
currentEmblemCompoundMaxInputs = 5
currentEmblemCompoundInputSize = 6
currentDisjointMaxRewards = 32
)
var currentDisjointBatchMarker = [4]byte{'B', '2', '6', 1}
var (
errCurrentDisjointRequestInvalid = errors.New("dnf current disjoint request is invalid")
errCurrentDisjointUnavailable = errors.New("dnf current disjoint source or PVF mapping is unavailable")
errCurrentDisjointSourceInvalid = errors.New("dnf current disjoint source item is invalid")
// errCurrentDisjointSourceLocked 是"源物品处于锁定状态"这一条独立的拒绝原因:
// 它必须能和 errCurrentDisjointSourceInvalid(槽位空/数量不对/PVF 对不上)区分开,
// 否则客户端只能收到一个笼统的失败码。装备锁的定义与出售/丢弃/交易/邮寄共用
// equipment_lock_state 口径(见 currentNPCShopItemLocked)。
errCurrentDisjointSourceLocked = errors.New("dnf current disjoint source item is equipment locked")
errCurrentDisjointRewardInvalid = errors.New("dnf current disjoint reward mapping is invalid")
)
// currentDisjointFailureCode 把一次被拒绝的分解/合成映射成客户端那侧的失败码。
//
// 原本所有失败一律回 4;对 op26 来说 4 是"物品栏空间不足,无法获得分解物品。",
// 锁定装备被拒时玩家会看到一句和原因完全无关的背包提示。锁定单独拿一个码。
//
// currentDisjointLockedFailureCode = 214 是**反查确认**过的:
// 客户端通用映射器 0x032DDC70 里 `cmp eax, 0xd6 / jne` → `push 0; push 0xa4f8`
// → GetText(0x0367BE00) → show(0x02FDD1A0),即 214 → dstr 42232
// 「在物品已锁定状态下,您无法进行当前操作。」
// 而 op26 的失败分支 0x01D293F1(jt 0x1D29560 / bt 0x1D29588,idx = code-4,
// bound 0xAF → 码 4..175)对范围外的码落到 0x01D294F5 → `call 0x32DDC70`,
// 所以 214 在 op26 上会渲染 42232。op18/op47/op256 走同一条 fallback;
// op202/op80/op271/op370 有显式的 214 分支。
const (
currentDisjointFailureCode = byte(4)
currentDisjointLockedFailureCode = byte(214)
)
func currentDisjointFailureResult(err error) byte {
if errors.Is(err, errCurrentDisjointSourceLocked) {
return currentDisjointLockedFailureCode
}
return currentDisjointFailureCode
}
// currentDisjointItemRequest is the exact semantic body written by the
// current client's op26 sender: target slot, item-space, portable-disjoint
// slot, and (on some UI paths) a u32 context. The reply reader consumes the
// first three fields again before it reads result rows.
type currentDisjointItemRequest struct {
SourceSlot int16
ListType byte
ToolSlot int16
Context uint32
}
// currentDisjointBatchRequest is emitted only by the pinned current-client
// compatibility hook after its native "disjoint all" loop has selected and
// validated the exact equipment slots. This envelope carries the complete
// selected set as one command.
type currentDisjointBatchRequest struct {
ListType byte
ToolSlot int16
Context uint32
Slots []int16
}
type currentAvatarDisjointRequest struct {
SourceSlot int16
ExpectedItemID uint32
}
type currentEmblemCompoundInput struct {
ItemID uint32
Slot int16
}
type currentEmblemCompoundRequest struct {
Inputs []currentEmblemCompoundInput
}
type currentDisjointReward struct {
ItemID uint32
Count uint32
}
type currentDisjointRewardSlot struct {
Slot int16
ItemID uint32
// Count 是**写入后的整堆终值**(老存量 + 本次),它只用于随后的 0x14 增量行,
// 由发那行的仓库刷新重新读出来,不直接进任何弹窗体。
Count uint32
// Granted 是**本次真正新增的量**,也是 op26/op202/op235/op256 弹窗体里
// 那个数量字段的口径(当前客户端把它当"获得 N 个"直接渲染)。
// 同族的四条路径都发 Granted:buildCurrentDisjointItemSuccessBody(op26)、
// mergeCurrentDisjointBatchRewards(op26 批量)、expert_job_store.go(op235)、
// buildCurrentEmblemCompoundSuccessBody(op256)。op202 早先发的是 Count,
// 于是背包里已有同款徽章时弹窗会把老存量一起算进去(显示数量虚高、实际不多给)。
Granted uint32
}
type currentDisjointResult struct {
Rewards []currentDisjointRewardSlot
Updates []currentItemListEntry
}
type currentDisjointAdditionalConst struct {
GreatDivisor float64
NormalDivisor float64
GreatChance float64
NormalChance float64
}
type currentDisjointExpand struct {
Enabled bool
ItemID uint32
LevelDivisor float64
GreatChance float64
NormalChance float64
}
type currentDisjointPVFConfig struct {
CubeItemID uint32
CubeBase float64
CubeMultipliers []float64
EquipmentSellRate int64
IndependentByItem map[uint32][]currentDisjointReward
Additional [][]uint32
AdditionalConsts []currentDisjointAdditionalConst
Expands []currentDisjointExpand
AvatarByJob map[string][]uint32 // legacy disjoint.etc [avatar disjoint info], kept for config validation
AvatarEmblemTables []currentAvatarEmblemGradeTable // etc/avatardisjoint/emblemlistinfo_<grade>.etc
EmblemBoosters map[[2]int]uint32 // (emblem grade,input count) -> booster item
}
// currentAvatarEmblemGradeTable is one grade's avatar disjoint reward table
// (86JP AvatarDisjointConfigProvider grammar): each [result info] section is
// one pool whose first value is the pick count, followed by
// item/weight/count/special 4-tuples.
type currentAvatarEmblemGradeTable struct {
Pools []currentAvatarEmblemPool
}
type currentAvatarEmblemPool struct {
PickCount int
Rewards []currentAvatarEmblemReward
}
type currentAvatarEmblemReward struct {
ItemID uint32
Weight uint32
Count uint32
Special bool
}
func parseCurrentDisjointItemRequest(body []byte) (currentDisjointItemRequest, error) {
if len(body) != currentDisjointItemRequestSize && len(body) != currentDisjointItemRequestSize+4 {
return currentDisjointItemRequest{}, fmt.Errorf("%w: op26 body=%d", errCurrentDisjointRequestInvalid, len(body))
}
request := currentDisjointItemRequest{
SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2])),
ListType: body[2],
ToolSlot: int16(binary.LittleEndian.Uint16(body[3:5])),
}
if len(body) == currentDisjointItemRequestSize+4 {
request.Context = binary.LittleEndian.Uint32(body[5:9])
}
if request.ListType != dnfrepo.MainInventoryListType || request.SourceSlot <= 0 ||
dnfrepo.IsAccountSharedInventorySlot(request.ListType, request.SourceSlot) {
return currentDisjointItemRequest{}, fmt.Errorf("%w: op26 list=%d slot=%d", errCurrentDisjointRequestInvalid, request.ListType, request.SourceSlot)
}
return request, nil
}
func parseCurrentDisjointBatchRequest(body []byte) (currentDisjointBatchRequest, bool, error) {
if len(body) < currentDisjointBatchHeaderSize ||
int16(binary.LittleEndian.Uint16(body[0:2])) != -1 ||
body[9] != currentDisjointBatchMarker[0] ||
body[10] != currentDisjointBatchMarker[1] ||
body[11] != currentDisjointBatchMarker[2] ||
body[12] != currentDisjointBatchMarker[3] {
return currentDisjointBatchRequest{}, false, nil
}
count := int(body[13])
if count < 1 || count > currentDisjointBatchMaxItems || len(body) != currentDisjointBatchHeaderSize+count*2 {
return currentDisjointBatchRequest{}, true, fmt.Errorf("%w: op26 batch count=%d body=%d", errCurrentDisjointRequestInvalid, count, len(body))
}
request := currentDisjointBatchRequest{
ListType: body[2],
ToolSlot: int16(binary.LittleEndian.Uint16(body[3:5])),
Context: binary.LittleEndian.Uint32(body[5:9]),
Slots: make([]int16, 0, count),
}
if request.ListType != dnfrepo.MainInventoryListType {
return currentDisjointBatchRequest{}, true, fmt.Errorf("%w: op26 batch list=%d", errCurrentDisjointRequestInvalid, request.ListType)
}
seen := make(map[int16]struct{}, count)
for index := 0; index < count; index++ {
offset := currentDisjointBatchHeaderSize + index*2
slot := int16(binary.LittleEndian.Uint16(body[offset : offset+2]))
if slot <= 0 || dnfrepo.IsAccountSharedInventorySlot(request.ListType, slot) {
return currentDisjointBatchRequest{}, true, fmt.Errorf("%w: op26 batch index=%d slot=%d", errCurrentDisjointRequestInvalid, index, slot)
}
if _, duplicate := seen[slot]; duplicate {
return currentDisjointBatchRequest{}, true, fmt.Errorf("%w: op26 batch duplicate slot=%d", errCurrentDisjointRequestInvalid, slot)
}
seen[slot] = struct{}{}
request.Slots = append(request.Slots, slot)
}
return request, true, nil
}
func parseCurrentAvatarDisjointRequest(body []byte) (currentAvatarDisjointRequest, error) {
if len(body) != currentAvatarDisjointRequestMinSize && len(body) != currentAvatarDisjointRequestFullSize {
return currentAvatarDisjointRequest{}, fmt.Errorf("%w: op202 body=%d", errCurrentDisjointRequestInvalid, len(body))
}
request := currentAvatarDisjointRequest{SourceSlot: int16(binary.LittleEndian.Uint16(body[0:2]))}
if len(body) == currentAvatarDisjointRequestFullSize {
request.ExpectedItemID = binary.LittleEndian.Uint32(body[2:6])
}
if request.SourceSlot < 0 {
return currentAvatarDisjointRequest{}, fmt.Errorf("%w: op202 slot=%d", errCurrentDisjointRequestInvalid, request.SourceSlot)
}
return request, nil
}
func parseCurrentEmblemCompoundRequest(body []byte) (currentEmblemCompoundRequest, error) {
if len(body) < 1 {
return currentEmblemCompoundRequest{}, errCurrentDisjointRequestInvalid
}
count := int(body[0])
if count < currentEmblemCompoundMinInputs || count > currentEmblemCompoundMaxInputs || len(body) != 1+count*currentEmblemCompoundInputSize {
return currentEmblemCompoundRequest{}, fmt.Errorf("%w: op256 count=%d body=%d", errCurrentDisjointRequestInvalid, count, len(body))
}
request := currentEmblemCompoundRequest{Inputs: make([]currentEmblemCompoundInput, 0, count)}
for index := 0; index < count; index++ {
offset := 1 + index*currentEmblemCompoundInputSize
input := currentEmblemCompoundInput{ItemID: binary.LittleEndian.Uint32(body[offset : offset+4]), Slot: int16(binary.LittleEndian.Uint16(body[offset+4 : offset+6]))}
if input.ItemID == 0 || input.Slot <= 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, input.Slot) {
return currentEmblemCompoundRequest{}, fmt.Errorf("%w: op256 input=%d item=%d slot=%d", errCurrentDisjointRequestInvalid, index, input.ItemID, input.Slot)
}
request.Inputs = append(request.Inputs, input)
}
return request, nil
}
func buildCurrentDisjointItemSuccessBody(request currentDisjointItemRequest, rewards []currentDisjointRewardSlot) []byte {
var body packetWriter
body.writeByte(1)
body.writeUint16(uint16(request.SourceSlot))
body.writeByte(request.ListType)
body.writeUint16(uint16(request.ToolSlot))
body.writeByte(byte(len(rewards)))
for _, reward := range rewards {
body.writeUint16(uint16(reward.Slot))
body.writeUint32(reward.ItemID)
body.writeUint32(reward.Granted)
}
return body.bytes()
}
// buildCurrentAvatarDisjointSuccessBody 编 op202 的"重铸成功"弹窗体。
//
// 每条奖励行最后那个 u32 是**本次新增量**(Granted),不是合并后的整堆终值:
// 当前客户端把它当"获得 N 个"渲染,而整堆终值由紧随其后的 0x14 增量行负责。
// 这里曾经发的是 reward.Count,于是"背包里已有同款徽章"时弹窗数量会等于
// 老存量 + 本次产出(玩家看到数字虚高、背包却不多给),与同族的 op26/op235/op256
// 口径也不一致。回归见 TestCurrentAvatarDisjointPopupReportsGrantedNotFinalStack。
func buildCurrentAvatarDisjointSuccessBody(request currentAvatarDisjointRequest, rewards []currentDisjointRewardSlot) []byte {
var body packetWriter
body.writeByte(1)
body.writeUint16(uint16(request.SourceSlot))
body.writeUint16(uint16(len(rewards)))
for _, reward := range rewards {
body.writeUint16(uint16(reward.Slot))
body.writeUint32(reward.ItemID)
body.writeUint32(reward.Granted)
}
return body.bytes()
}
func buildCurrentEmblemCompoundSuccessBody(reward currentDisjointRewardSlot) []byte {
var body packetWriter
body.writeByte(1)
body.writeByte(1)
body.writeUint32(reward.ItemID)
body.writeUint32(reward.Granted)
return body.bytes()
}