服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
421 lines
17 KiB
Go
421 lines
17 KiB
Go
// 本文件负责礼包/随机盒子命令的协议分发。
|
||
// 礼包开启会消耗物品并发放称号、时装、宠物或道具,当前只解析请求体,不直接回成功包。
|
||
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
|
||
}
|