Files
90CNServer/server/internal/modules/dnf/packageitem/handler.go
T
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

421 lines
17 KiB
Go
Raw 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 packageitem
import (
"context"
"encoding/binary"
"fmt"
"strconv"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
// NewHandler 创建礼包协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回礼包业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainPackage
}
// Handle 解析礼包请求,并在奖励和物品刷新链路闭合前禁止成功 ACK。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketUseBoosterItem:
parsed, err := DecodeSelectablePackageRequest(req.Body)
cmd := NewSelectableCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.PlanSelectable(ctx, SelectableCommand{
SelectedCharacterID: req.SelectedCharacterID,
SlotIndex: parsed.SlotIndex,
SelectedItemTemplateID: parsed.SelectedItemTemplateID,
})
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerPlannedResult(cmd.Operation, cmd.String(), result), nil
case dnfenum.CmdPacketUseRandomboxItem:
parsed, err := DecodeMagicBoxSingleRequest(req.Body)
cmd := NewMagicBoxCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
if req.MagicBoxResolver == nil || req.MagicBoxRewardItemResolver == nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("magic box blocked: runtime-PVF resolvers unavailable; refusing to trust request metadata: %v", ErrMagicBoxResolverRequired),
}, nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyMagicBox(ctx, MagicBoxCommand{
SelectedCharacterID: req.SelectedCharacterID,
AccountID: req.AccountID,
RawListType: parsed.RawListType,
ListType: parsed.ListType,
SlotIndex: parsed.SlotIndex,
MaterialSlotIndex: parsed.MaterialSlotIndex,
ActivateContractUnits: req.MagicBoxContractActivator,
}, req.MagicBoxResolver, req.MagicBoxRewardItemResolver)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedMagicBoxResult(req.Opcode, cmd.Operation, result), nil
case dnfenum.CmdPacketUseRandomboxItemExpand:
parsed, err := DecodeMagicBoxExpandRequest(req.Body)
cmd := NewMagicBoxExpandCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
if req.MagicBoxResolver == nil || req.MagicBoxRewardItemResolver == nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("magic box expand blocked: runtime-PVF resolvers unavailable; refusing to trust request metadata: %v", ErrMagicBoxResolverRequired),
}, nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyMagicBoxExpand(ctx, MagicBoxExpandCommand{
SelectedCharacterID: req.SelectedCharacterID,
AccountID: req.AccountID,
RawListType: parsed.RawListType,
ListType: parsed.ListType,
SlotIndex: parsed.SlotIndex,
BoxItemID: parsed.BoxItemID,
MaterialSlotIndex: parsed.MaterialSlotIndex,
MaterialItemID: parsed.MaterialItemID,
OpenCount: parsed.OpenCount,
ActivateContractUnits: req.MagicBoxContractActivator,
}, req.MagicBoxResolver, req.MagicBoxRewardItemResolver)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedMagicBoxResult(req.Opcode, cmd.Operation, result), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("package 模块未登记 opcode %d", req.Opcode),
}, nil
}
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;礼包奖励、物品刷新和弹窗顺序闭合前禁止回包", operation, summary),
}
}
func ownerBlockedResult(operation string, summary string, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;package owner 预检未通过:%v;禁止回成功包", operation, summary, err),
}
}
func ownerPlannedResult(operation string, summary string, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;package owner 已验证 char=%s source=(%d,%d) item=%d materialSlot=%d materialItem=%d selected=%d;奖励发放/物品刷新/弹窗顺序未闭合,禁止回成功包", operation, summary, result.CharacterID, result.SourceListType, result.SourceSlotIndex, result.SourceItemID, result.MaterialSlotIndex, result.MaterialItemID, result.SelectedItemTemplateID),
}
}
func ownerAppliedMagicBoxResult(opcode uint16, operation string, result MagicBoxResult) alignedcmd.Result {
if !result.Success {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: fmt.Sprintf("magic box owner rejected: %s; returned {0x00} failure in family order", result.Reason),
UpperResponses: []alignedcmd.UpperResponse{
class1Response(opcode, buildMagicBoxFailureAck()),
},
}
}
ackBody := buildMagicBoxSingleAck(result)
responses := []alignedcmd.UpperResponse{
class1Response(opcode, ackBody),
}
if dnfenum.CmdPacket(opcode) == dnfenum.CmdPacketUseRandomboxItemExpand {
if result.BoxItemID == currentSeriaLuckItemID {
// 赛丽亚十连由原生批量回包独占结果展示。额外发送 op208
// 会用单开“三格获得道具”弹窗覆盖双栏十连结果。
responses = []alignedcmd.UpperResponse{
class1Response(opcode, buildMagicBoxSeriaBatchAck(result)),
}
} else {
// 非赛丽亚源(泰迪礼盒等):0x0468 原生回包在客户端 sub_1D074C0
// 末尾固定弹一条收尾公告(当前 2018 字符串表把它渲染成错误文案),
// 而其结果窗口由 208 通道完整承载,故只发 208 展示回包(2026-07-26
// 实测:1128 未实现时客户端也无等待卡死,可安全省略)。
responses = []alignedcmd.UpperResponse{
class1Response(uint16(dnfenum.CmdPacketUseRandomboxItem), buildMagicBoxSingleAck(result)),
}
}
}
out := alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: fmt.Sprintf(
"package owner applied: %s box=%d opens=%d rewards=%d material=%d changed=%t; opened exactly one result ACK then incremental changed-slot refreshes",
operation,
result.BoxItemID,
result.OpenCount,
len(result.Rewards),
result.MaterialItemID,
result.Changed,
),
UpperResponses: responses,
ItemSlotRefreshes: magicBoxChangedSlotRefreshes(result),
}
if result.BoxItemID == currentSeriaLuckItemID {
// The account owner has already committed the absolute Seria-luck value.
// Keep it out of the native magic-box result body: dnfbridge sends this
// projection only after the result ACK and changed inventory rows, so the
// current client's opening animation remains authoritative while its HUD
// is ready for another open without a character reselect.
out.SeriaLuck = &alignedcmd.SeriaLuckProjection{
Before: result.SeriaLuckBefore,
After: result.SeriaLuckAfter,
}
}
if result.OverflowMailID != "" {
// The durable system mail is already committed. Send the exact current
// client alarm only after the normal box ACK and item refresh complete.
out.MailboxAlarmRecipientID = uint16(parseMagicBoxCharacterID(result.CharacterID))
}
if len(result.DirectActivations) > 0 {
// Premium contracts never reached the bag, so the bridge must extend the
// account entitlement itself and notify the client with the ordinary
// premium activation packets instead of item rows.
out.ContractActivations = result.DirectActivations
}
return out
}
// magicBoxChangedSlotRefreshes follows the proved 86JP post-open sequence:
// publish only the source box, optional material, and actual reward slots that
// changed. A full list-0/list-1/list-2/list-7/list-38 replay is not part of the
// native magic-box flow. Besides being unnecessary, that replay also made the
// bridge treat a bag-only mutation as worn-equipment state and resend the
// private combat projection, temporarily replacing live collection/buff stats.
func magicBoxChangedSlotRefreshes(result MagicBoxResult) []alignedcmd.ItemSlotRefresh {
if !result.Changed {
return nil
}
seen := make(map[string]struct{}, 2+len(result.Rewards))
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, 2+len(result.Rewards))
appendSlot := func(listType byte, slot int16) {
if slot < 0 {
return
}
key := fmt.Sprintf("%d:%d", listType, slot)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
ListType: listType,
SlotIndex: slot,
})
}
if len(result.ChangedSlots) > 0 {
for _, changed := range result.ChangedSlots {
appendSlot(changed.ListType, changed.SlotIndex)
}
return refreshes
}
appendSlot(listTypeMain, result.BoxSlotIndex)
appendSlot(listTypeMain, result.MaterialSlotIndex)
for _, reward := range result.Rewards {
appendSlot(listTypeMain, reward.Slot)
}
return refreshes
}
func parseMagicBoxCharacterID(raw string) uint64 {
value, err := strconv.ParseUint(raw, 10, 16)
if err != nil {
return 0
}
return value
}
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
// buildMagicBoxFailureAck is the magic-box family's uniform failure body:
// one zero byte, no error-code subdivision (86JP family rule).
func buildMagicBoxFailureAck() []byte {
return []byte{0x00}
}
// buildMagicBoxSingleAck follows the current EXE's sub_1D05A60 read order.
// The class1 dispatcher consumes result=1 first; the handler then always
// reads u8 clientType and u8 doubleFlag before switching on clientType.
// Direct Seria opens use case 4, which reads i16 boxSlot and materialSlot at
// 0x01D06815/0x01D06821, then sub_1D057B0 reads u16 rewardCount and complete
// 0x77-byte rows at 0x01D0698A. Omitting doubleFlag shifts both slots and the
// reward count, leaving every result row in the client's "loading" state.
func buildMagicBoxSingleAck(result MagicBoxResult) []byte {
doubleFlag := byte(0)
if result.SeriaLuckDoubleTriggered {
doubleFlag = 1
}
out := []byte{1, result.ClientType, doubleFlag}
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, result.MaterialSlotIndex)
out = appendI16(out, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
func appendI16(out []byte, value int16) []byte {
return append(out, byte(value), byte(value>>8))
}
func appendI32(out []byte, value int32) []byte {
return append(out, byte(value), byte(value>>8), byte(value>>16), byte(value>>24))
}
// buildMagicBoxSeriaBatchAck 是赛丽亚源连开(0x0468)的原生批量回包,结构
// 以当前 EXE sub_1D074C0 的实读顺序为准(IDA/x32dbg 证据):u8 success=4,
// u8 variant=4
// (无材料盒子,跳过客户端材料扣减校验,否则对 0xffff 材料槽误报 11031),
// u8 doubleFlag(86JP SeriaLuck 翻倍触发时为 1),u16 openCount,
// i16 boxSlot, i16 materialSlot(无材料=-1),随后第一奖励列表(u16 count +
// count × 0x77 完整物品条目:u16 slot, u32 itemID, u32 count,其余字节为
// 零),u16 0,第二(双倍)奖励列表同构。variant=4 且 doubleFlag!=0 时
// 客户端走 0x36B 赛丽亚抽奖双栏窗口;doubleFlag=0 时走 0x145 单栏结果。
// 行宽 0x77 是当前 EXE 列表读取器 sub_1D057B0 的硬编码。
func buildMagicBoxSeriaBatchAck(result MagicBoxResult) []byte {
doubleFlag := byte(0)
if len(result.DoubleRewards) > 0 {
doubleFlag = 1
}
out := []byte{4, 4, doubleFlag}
out = appendI16(out, int16(result.OpenCount))
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, result.MaterialSlotIndex)
// 左栏(普通)= 基础抽取 DisplayRewards,右栏(双倍)= DoubleRewards;
// 两个列表重复会让客户端合并清空左栏(86JP GetPrimaryRewards 契约)。
out = appendI16(out, int16(len(result.DisplayRewards)))
for _, reward := range result.DisplayRewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
out = appendI16(out, 0)
out = appendI16(out, int16(len(result.DoubleRewards)))
for _, reward := range result.DoubleRewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
func buildMagicBoxBatchRewardRow(reward MagicBoxGrantedReward) []byte {
row := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(row[0x00:0x02], uint16(reward.Slot))
binary.LittleEndian.PutUint32(row[0x02:0x06], uint32(reward.ItemID))
if reward.Kind == "equipment" {
// 86JP 契约:装备行的 0x06 字段是品质种子而非数量,0x0B 是耐久;
// 写成数量会让客户端结果窗口渲染为"加载中"(2026-07-26 实测)。
seed := reward.QualitySeed
if seed == 0 {
seed = currentMagicBoxTopQualitySeed
}
binary.LittleEndian.PutUint32(row[0x06:0x0A], seed)
binary.LittleEndian.PutUint16(row[0x0B:0x0D], reward.Durability)
} else {
binary.LittleEndian.PutUint32(row[0x06:0x0A], uint32(reward.Count))
}
return row
}
// buildMagicBoxExpandAck 是非赛丽亚源连开(0x0468)的原生回包。结构以当前
// EXE sub_1D074C0 的实读顺序为准:u8 variant=1, u8 clientType=4(走
// 0x1FC 分支),u16 openCount, i16 boxSlot, i16 materialSlot(无材料回填
// 盒子槽,配合随后的 op14 刷新对齐),空第一列表 + u16 0 + 第二奖励列表
// (0x77 完整物品条目:u16 slot, u32 itemID, u32 count,其余字节为零)。
func buildMagicBoxExpandAck(result MagicBoxResult) []byte {
materialSlot := result.MaterialSlotIndex
if result.MaterialItemID == 0 {
materialSlot = result.BoxSlotIndex
}
out := []byte{1, 4}
out = appendI16(out, int16(result.OpenCount))
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, materialSlot)
out = appendI16(out, 0)
out = appendI16(out, 0)
out = appendI16(out, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
// buildCurrentBoosterPopupAck 是非赛丽亚源连开的 0x00A0 传统获得物品弹窗。
// 布局以当前 EXE 处理器 sub_1D2BFF0 的实读顺序为准(2026-07-26 IDA):
// i32 1, i16 sourceSlot, i32 0, i32 0, u16 count,随后每行
// (i32 itemID, i32 count)。86JP 的 u8 头布局与该客户端读取器不对齐,
// 会渲染出乱码行,故按 IDA 证据构造。
func buildCurrentBoosterPopupAck(result MagicBoxResult) []byte {
var writer []byte
writer = appendI32(writer, 1)
writer = appendI16(writer, result.BoxSlotIndex)
writer = appendI32(writer, 0)
writer = appendI32(writer, 0)
writer = appendI16(writer, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
writer = appendI32(writer, int32(reward.ItemID))
writer = appendI32(writer, int32(reward.Count))
}
return writer
}