DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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()
|
||||
}
|
||||
Reference in new issue
Block a user