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,78 @@
// 本文件定义礼包和随机盒命令计划。
// 计划只记录开包意图,不直接消耗物品、发奖励或触发弹窗。
package packageitem
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 packageitem handler 交给后续 reward/package owner 的最小命令计划。
// 礼包可能产出称号、时装、宠物和道具,必须由奖励 owner 严格按 EXE 包体顺序落地。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
SlotIndex int16
SelectionContext int16
SelectedItemTemplateID int32
SelectionFlag byte
AvatarChoiceCount int
ListType byte
RawListType byte
MaterialSlotIndex int16
NeedsOwner string
}
// NewSelectableCommand 构造可选礼包开启计划。
func NewSelectableCommand(req alignedcmd.Request, parsed SelectablePackageRequest) Command {
return Command{
Operation: "use_booster_item",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
SlotIndex: parsed.SlotIndex,
SelectionContext: parsed.SelectionContext,
SelectedItemTemplateID: parsed.SelectedItemTemplateID,
SelectionFlag: parsed.SelectionFlag,
AvatarChoiceCount: parsed.AvatarChoiceCount,
NeedsOwner: "inventory owner + package reward owner + mutation id + popup/NOTI order",
}
}
// NewMagicBoxCommand 构造随机盒单开计划。
func NewMagicBoxCommand(req alignedcmd.Request, parsed MagicBoxSingleRequest) Command {
return Command{
Operation: "use_randombox_item",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ListType: parsed.ListType,
RawListType: parsed.RawListType,
SlotIndex: parsed.SlotIndex,
MaterialSlotIndex: parsed.MaterialSlotIndex,
NeedsOwner: "inventory owner + random reward owner + mutation id + popup/NOTI order",
}
}
// NewMagicBoxExpandCommand 构造随机盒连开(全部开启)计划。
func NewMagicBoxExpandCommand(req alignedcmd.Request, parsed MagicBoxExpandRequest) Command {
return Command{
Operation: "use_randombox_item_expand",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ListType: parsed.ListType,
RawListType: parsed.RawListType,
SlotIndex: parsed.SlotIndex,
MaterialSlotIndex: parsed.MaterialSlotIndex,
NeedsOwner: "inventory owner + random reward owner + mutation id + popup/NOTI order",
}
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
if c.Operation == "use_randombox_item" || c.Operation == "use_randombox_item_expand" {
return fmt.Sprintf("%s list=%d slot=%d materialSlot=%d needs=%s", base, c.ListType, c.SlotIndex, c.MaterialSlotIndex, c.NeedsOwner)
}
return fmt.Sprintf("%s slot=%d context=%d selected=%d flag=%d avatarChoices=%d needs=%s", base, c.SlotIndex, c.SelectionContext, c.SelectedItemTemplateID, c.SelectionFlag, c.AvatarChoiceCount, c.NeedsOwner)
}
@@ -0,0 +1,2 @@
// Package packageitem 承接 DNF 礼包、随机盒子和选择类消耗品 C2S 协议。
package packageitem
@@ -0,0 +1,420 @@
// 本文件负责礼包/随机盒子命令的协议分发。
// 礼包开启会消耗物品并发放称号、时装、宠物或道具,当前只解析请求体,不直接回成功包。
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
}
@@ -0,0 +1,338 @@
package packageitem
import (
"context"
"encoding/binary"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// magicBoxExpandCapturedBody 是 2026-07-25 在当前 NoPack 客户端上实测抓到
// 的 0x0468 请求体:主背包 70 号槽的 10007368(泰迪礼盒)连开 100 次,材料
// 槽 151 的 10007367(幸运魔锤)。
var magicBoxExpandCapturedBody = []byte{
0x00,
0x46, 0x00,
0x48, 0xB3, 0x98, 0x00,
0x97, 0x00,
0x47, 0xB3, 0x98, 0x00,
0x64, 0x00,
}
func TestDecodeMagicBoxExpandRequestMatchesCapturedClientBody(t *testing.T) {
req, err := DecodeMagicBoxExpandRequest(magicBoxExpandCapturedBody)
if err != nil {
t.Fatalf("decode error = %v", err)
}
if req.ListType != listTypeMain || req.SlotIndex != 70 || req.BoxItemID != 10007368 ||
req.MaterialSlotIndex != 151 || req.MaterialItemID != 10007367 || req.OpenCount != 100 {
t.Fatalf("request = %+v", req)
}
}
func TestDecodeMagicBoxExpandRequestRejectsMalformed(t *testing.T) {
for name, body := range map[string][]byte{
"short": {0, 1, 0},
"bad list": {9, 0x46, 0, 0x48, 0xB3, 0x98, 0, 0x97, 0, 0x47, 0xB3, 0x98, 0, 0x64, 0},
"zero box": {0, 0x46, 0, 0, 0, 0, 0, 0x97, 0, 0x47, 0xB3, 0x98, 0, 0x64, 0},
"zero count": {0, 0x46, 0, 0x48, 0xB3, 0x98, 0, 0x97, 0, 0x47, 0xB3, 0x98, 0, 0, 0},
} {
if _, err := DecodeMagicBoxExpandRequest(body); err == nil {
t.Fatalf("%s: malformed body accepted", name)
}
}
}
func magicBoxExpandTestCommand(boxSlot int16, materialSlot int16, count uint16) MagicBoxExpandCommand {
return MagicBoxExpandCommand{
SelectedCharacterID: 77,
RawListType: 0,
ListType: listTypeMain,
SlotIndex: boxSlot,
BoxItemID: 10007368,
MaterialSlotIndex: materialSlot,
MaterialItemID: 10007367,
OpenCount: count,
}
}
func magicBoxExpandBaseSlots() map[string]dnfrepo.ItemStack {
slots := magicBoxBaseSlots()
box := slots["0:10"]
box.Count = 3
slots["0:10"] = box
hammer := slots["0:121"]
hammer.Count = 5
slots["0:121"] = hammer
return slots
}
func TestOwnerApplyMagicBoxExpandConsumesRequestedOpens(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxExpandBaseSlots())
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyMagicBoxExpand(ctx, magicBoxExpandTestCommand(10, 121, 2), staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBoxExpand error = %v", err)
}
if !result.Success || result.OpenCount != 2 || len(result.Rewards) != 2 {
t.Fatalf("result = %+v", result)
}
// 2682272 在测试 PVF 里是 [stack limit] 1:【一个容器里该道具最多一件】,
// 所以两次开出的第二件不能再占第二行,按本路径既有规则转系统邮件。
if result.Rewards[0].ItemID != 2600014 || result.Rewards[0].Count != 4 || result.Rewards[1].ItemID != 2682272 || result.Rewards[1].Count != 1 {
t.Fatalf("rewards = %+v", result.Rewards)
}
if result.OverflowMailID == "" {
t.Fatalf("stack-limited remainder must be delivered by mail: %+v", result)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2682272 || mail.Attachments[0].Count != 1 {
t.Fatalf("overflow mail = %+v", mail)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
if box := loaded.Slots["0:10"]; box.Count != 1 {
t.Fatalf("box = %+v, want 1 left", box)
}
if hammer := loaded.Slots["0:121"]; hammer.Count != 3 {
t.Fatalf("hammer = %+v, want 3 left", hammer)
}
}
func TestOwnerApplyMagicBoxExpandConvergesToAvailableMaterials(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxExpandBaseSlots()) // 3 boxes, 5 hammers -> cap 3
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyMagicBoxExpand(ctx, magicBoxExpandTestCommand(10, 121, 100), staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBoxExpand error = %v", err)
}
if !result.Success || result.OpenCount != 3 {
t.Fatalf("result = %+v, want converged opens 3", result)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
if _, found := loaded.Slots["0:10"]; found {
t.Fatalf("box slot should be consumed")
}
if hammer := loaded.Slots["0:121"]; hammer.Count != 2 {
t.Fatalf("hammer = %+v, want 2 left", hammer)
}
}
func TestOwnerApplyMagicBoxExpandRejectsItemMismatch(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxExpandBaseSlots())
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := magicBoxExpandTestCommand(10, 121, 1)
cmd.BoxItemID = 999999
result, err := owner.ApplyMagicBoxExpand(ctx, cmd, staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBoxExpand error = %v", err)
}
if result.Success || result.Changed || result.Reason == "" {
t.Fatalf("result = %+v, want rejected without mutation", result)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
if box := loaded.Slots["0:10"]; box.Count != 3 {
t.Fatalf("box mutated: %+v", box)
}
}
func handleMagicBoxExpandRequest(t *testing.T, repos dnfrepo.Group, body []byte) alignedcmd.Result {
t.Helper()
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseRandomboxItemExpand),
Body: body,
AccountID: "acc",
SelectedCharacterID: 77,
Repositories: repos,
MagicBoxResolver: staticMagicBoxResolver(magicBoxTestResolution()),
MagicBoxRewardItemResolver: staticMagicBoxRewardResolver(magicBoxTestRewardItems()),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
return got
}
func TestHandlerMagicBoxExpandSendsAggregatedAckThenIncrementalChangedSlots(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:70"] = dnfrepo.ItemStack{ItemID: 10007368, Count: 1, Extra: map[string]string{"item_kind": "stackable"}}
slots["0:151"] = dnfrepo.ItemStack{ItemID: 10007367, Count: 1, Extra: map[string]string{"item_kind": "stackable"}}
delete(slots, "0:10")
delete(slots, "0:121")
saveMagicBoxFixture(t, ctx, repos, slots)
body := make([]byte, 0, 15)
body = append(body, 0x00, 0x46, 0x00, 0x48, 0xB3, 0x98, 0x00, 0x97, 0x00, 0x47, 0xB3, 0x98, 0x00, 0x64, 0x00)
got := handleMagicBoxExpandRequest(t, repos, body)
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if got.SeriaLuck != nil {
t.Fatalf("ordinary expand SeriaLuck projection=%+v, want nil", got.SeriaLuck)
}
display := got.UpperResponses[0]
if display.MsgID != uint16(dnfenum.CmdPacketUseRandomboxItem) {
t.Fatalf("display msg = 0x%04X, want 0x00D0", display.MsgID)
}
if len(got.PostActions) != 0 {
t.Fatalf("post actions=%v, want no full container/combat projection", got.PostActions)
}
wantRefreshes := []alignedcmd.ItemSlotRefresh{
{ListType: listTypeMain, SlotIndex: 65},
{ListType: listTypeMain, SlotIndex: 66},
{ListType: listTypeMain, SlotIndex: 70},
{ListType: listTypeMain, SlotIndex: 151},
}
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
t.Fatalf("slot refreshes=%v, want %v", got.ItemSlotRefreshes, wantRefreshes)
}
}
func TestHandlerMagicBoxExpandFailureAcksZeroByte(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxBaseSlots()) // captured slots 70/151 empty here
got := handleMagicBoxExpandRequest(t, repos, magicBoxExpandCapturedBody)
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if string(got.UpperResponses[0].Body) != string([]byte{0x00}) {
t.Fatalf("failure ack = %x, want {00}", got.UpperResponses[0].Body)
}
}
// TestHandlerMagicBoxExpandSeriaBatchAckMatchesCurrentEXELayout 锁定赛丽亚源
// 连开的原生批量回包(结构以当前 EXE sub_1D074C0 实读顺序为准):variant=4
// 跳过材料校验,doubleFlag 反映翻倍,主列表 + u16 0 + 双倍列表,0x77 行。
// 幸运值从 0 开 10 个时第 9 轮必触发一次翻倍(86JP max=8)。
func TestHandlerMagicBoxExpandSeriaBatchAckMatchesCurrentEXELayout(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := map[string]dnfrepo.ItemStack{
"0:67": {ItemID: 2682272, Count: 10, Extra: map[string]string{"item_kind": "stackable"}},
}
saveMagicBoxFixture(t, ctx, repos, slots)
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}}}},
}
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseRandomboxItemExpand),
Body: []byte{0x04, 0x43, 0x00, 0xA0, 0xED, 0x28, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x0A, 0x00},
AccountID: "acc",
SelectedCharacterID: 77,
Repositories: repos,
MagicBoxResolver: staticMagicBoxResolver(resolution),
MagicBoxRewardItemResolver: staticMagicBoxRewardResolver(map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", SlotStart: 65, SlotEnd: 120},
}),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if got.SeriaLuck == nil || got.SeriaLuck.Before != 0 || got.SeriaLuck.After != 2 {
t.Fatalf("SeriaLuck projection=%+v, want 0->2 after ten opens", got.SeriaLuck)
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketUseRandomboxItemExpand) {
t.Fatalf("ack msg = 0x%04X, want 0x0468", ack.MsgID)
}
row := func(slot uint16, itemID uint32, count uint32) []byte {
r := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(r[0x00:0x02], slot)
binary.LittleEndian.PutUint32(r[0x02:0x06], itemID)
binary.LittleEndian.PutUint32(r[0x06:0x0A], count)
return r
}
want := []byte{0x04, 0x04, 0x01, 0x0A, 0x00, 0x43, 0x00, 0xFF, 0xFF, 0x01, 0x00}
want = append(want, row(65, 2600014, 20)...)
want = append(want, 0x00, 0x00, 0x01, 0x00)
want = append(want, row(65, 2600014, 2)...)
if string(ack.Body) != string(want) {
t.Fatalf("ack body = %x, want %x", ack.Body, want)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
if _, found := loaded.Slots["0:67"]; found {
t.Fatalf("10 seria boxes should be consumed: %+v", loaded.Slots)
}
if potion := loaded.Slots["0:65"]; potion.ItemID != 2600014 || potion.Count != 22 {
t.Fatalf("granted potion = %+v, want 22", potion)
}
}
// TestOwnerApplyMagicBoxExpandSeriaLuckDoublePersistsPerAccount 验证 86JP
// SeriaLuck 账号级持久化:已有幸运值 7 时,开 2 个的第 1 轮翻倍(满 8),
// 第 2 轮正常,落库后幸运值为 1。
func TestOwnerApplyMagicBoxExpandSeriaLuckDoublePersistsPerAccount(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc", Metadata: map[string]string{currentSeriaLuckMetadataKey: "7"}}); err != nil {
t.Fatal(err)
}
saveMagicBoxFixture(t, ctx, repos, map[string]dnfrepo.ItemStack{
"0:67": {ItemID: 2682272, Count: 2, Extra: map[string]string{"item_kind": "stackable"}},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}}}},
}
result, err := owner.ApplyMagicBoxExpand(ctx, MagicBoxExpandCommand{
SelectedCharacterID: 77,
AccountID: "acc",
ListType: listTypeMain,
SlotIndex: 67,
BoxItemID: 2682272,
MaterialSlotIndex: -1,
OpenCount: 2,
}, staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", SlotStart: 65, SlotEnd: 120},
}))
if err != nil {
t.Fatalf("ApplyMagicBoxExpand error = %v", err)
}
if !result.Success || !result.SeriaLuckDoubleTriggered || result.SeriaLuckBefore != 7 || result.SeriaLuckAfter != 1 {
t.Fatalf("result = %+v", result)
}
if len(result.DoubleRewards) != 1 || result.DoubleRewards[0].ItemID != 2600014 || result.DoubleRewards[0].Count != 2 {
t.Fatalf("double rewards = %+v", result.DoubleRewards)
}
if len(result.Rewards) != 1 || result.Rewards[0].Count != 6 {
t.Fatalf("rewards = %+v, want aggregated 6", result.Rewards)
}
account, found, err := repos.Account.Load(ctx, "acc")
if err != nil || !found || account.Metadata[currentSeriaLuckMetadataKey] != "1" {
t.Fatalf("persisted luck found=%t err=%v metadata=%+v", found, err, account.Metadata)
}
}
@@ -0,0 +1,361 @@
package packageitem
import (
"context"
"encoding/binary"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func handleMagicBoxRequest(t *testing.T, repos dnfrepo.Group, boxResolver alignedcmd.MagicBoxResolver, rewardResolver alignedcmd.MagicBoxRewardItemResolver, body []byte) alignedcmd.Result {
t.Helper()
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseRandomboxItem),
Body: body,
AccountID: "acc",
SelectedCharacterID: 77,
Repositories: repos,
MagicBoxResolver: boxResolver,
MagicBoxRewardItemResolver: rewardResolver,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
return got
}
func magicBoxRequestBody(boxSlot int16, materialSlot int16) []byte {
body := []byte{0, byte(boxSlot), byte(boxSlot >> 8)}
if materialSlot >= 0 {
body = append(body, byte(materialSlot), byte(materialSlot>>8))
}
return body
}
func TestHandlerMagicBoxSendsSingleAckThenIncrementalChangedSlots(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxBaseSlots())
got := handleMagicBoxRequest(t, repos, staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()), magicBoxRequestBody(10, 121))
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if got.SeriaLuck != nil {
t.Fatalf("ordinary box SeriaLuck projection=%+v, want nil", got.SeriaLuck)
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketUseRandomboxItem) {
t.Fatalf("ack msg = 0x%04X, want 0x00D0", ack.MsgID)
}
row := func(slot uint16, itemID uint32, count uint32) []byte {
r := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(r[0x00:0x02], slot)
binary.LittleEndian.PutUint32(r[0x02:0x06], itemID)
binary.LittleEndian.PutUint32(r[0x06:0x0A], count)
return r
}
want := []byte{0x01, 0x00, 0x00, 0x0A, 0x00, 0x79, 0x00, 0x02, 0x00}
want = append(want, row(65, 2600014, 2)...)
want = append(want, row(66, 2682272, 1)...)
if string(ack.Body) != string(want) {
t.Fatalf("ack body = %x, want %x", ack.Body, want)
}
if got, want := len(ack.Body), 9+2*currentMagicBoxEntrySize; got != want {
t.Fatalf("ack body len = %d, want %d", got, want)
}
wantRefreshes := []alignedcmd.ItemSlotRefresh{
{ListType: listTypeMain, SlotIndex: 10},
{ListType: listTypeMain, SlotIndex: 65},
{ListType: listTypeMain, SlotIndex: 66},
{ListType: listTypeMain, SlotIndex: 121},
}
if len(got.PostActions) != 0 {
t.Fatalf("post actions=%v, want no full container/combat projection", got.PostActions)
}
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
t.Fatalf("slot refreshes=%v, want %v", got.ItemSlotRefreshes, wantRefreshes)
}
}
// 容器里已经有这道具的行、并且总量已经到了 PVF [stack limit](0:65 的 3 件加上
// 0:66 的 1 件):奖励不许再写第二行/写过上限,只刷新被消耗的盒子格,余量转系统邮件。
func TestOwnerApplyMagicBoxCapsGrantAtContainerStackLimit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:65"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 3, Extra: map[string]string{"item_kind": "stackable"}}
slots["0:66"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 1, Extra: map[string]string{"item_kind": "stackable"}}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{Kind: "random", Groups: []alignedcmd.MagicBoxRewardGroup{{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}}}}}
reward := alignedcmd.MagicBoxRewardItem{ItemID: 2600014, Kind: "stackable", StackLimit: 3, SlotStart: 65, SlotEnd: 120}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, -1), staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(map[int64]alignedcmd.MagicBoxRewardItem{2600014: reward}))
if err != nil || !result.Success {
t.Fatalf("result=%+v err=%v", result, err)
}
want := []MagicBoxChangedSlot{{ListType: listTypeMain, SlotIndex: 10}}
if !reflect.DeepEqual(result.ChangedSlots, want) {
t.Fatalf("changed slots=%v, want %v", result.ChangedSlots, want)
}
refreshes := magicBoxChangedSlotRefreshes(result)
wantRefreshes := []alignedcmd.ItemSlotRefresh{{ListType: listTypeMain, SlotIndex: 10}}
if !reflect.DeepEqual(refreshes, wantRefreshes) {
t.Fatalf("refreshes=%v, want %v", refreshes, wantRefreshes)
}
loaded, _, err := repos.Inventory.Load(ctx, "77")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if stack := loaded.Slots["0:65"]; stack.Count != 3 {
t.Fatalf("capped row was mutated: %+v", stack)
}
if stack := loaded.Slots["0:66"]; stack.Count != 1 {
t.Fatalf("capped row was mutated: %+v", stack)
}
for key, stack := range loaded.Slots {
if stack.ItemID == 2600014 && key != "0:65" && key != "0:66" {
t.Fatalf("reward opened an extra row at %s: %+v", key, stack)
}
}
if result.OverflowMailID == "" {
t.Fatalf("capped reward must go to overflow mail: %+v", result)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2600014 || mail.Attachments[0].Count != 2 {
t.Fatalf("overflow mail = %+v", mail)
}
}
func TestOwnerAppliedMagicBoxOverflowRequestsMailboxAlarm(t *testing.T) {
got := ownerAppliedMagicBoxResult(uint16(dnfenum.CmdPacketUseRandomboxItem), "use_magic_box", MagicBoxResult{
CharacterID: "77",
Success: true,
Changed: true,
OverflowMailID: "1",
})
if got.MailboxAlarmRecipientID != 77 {
t.Fatalf("mailbox alarm recipient = %d, want 77", got.MailboxAlarmRecipientID)
}
}
func TestOwnerAppliedMagicBoxSchedulesPostACKSeriaGaugeOnlyForSeriaSource(t *testing.T) {
tests := []struct {
name string
opcode uint16
boxItemID int64
wantGauge bool
}{
{name: "seria_single", opcode: uint16(dnfenum.CmdPacketUseRandomboxItem), boxItemID: currentSeriaLuckItemID, wantGauge: true},
{name: "seria_batch", opcode: uint16(dnfenum.CmdPacketUseRandomboxItemExpand), boxItemID: currentSeriaLuckItemID, wantGauge: true},
{name: "ordinary_single", opcode: uint16(dnfenum.CmdPacketUseRandomboxItem), boxItemID: 8001},
{name: "ordinary_batch", opcode: uint16(dnfenum.CmdPacketUseRandomboxItemExpand), boxItemID: 8001},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := ownerAppliedMagicBoxResult(test.opcode, "magic_box_test", MagicBoxResult{
Success: true,
Changed: true,
BoxItemID: test.boxItemID,
ClientType: clientMainListType,
BoxSlotIndex: 85,
MaterialSlotIndex: -1,
SeriaLuckBefore: 4,
SeriaLuckAfter: 5,
Rewards: []MagicBoxGrantedReward{{
Slot: 108,
ItemID: 10000391,
Count: 2,
}},
})
if test.wantGauge {
if got.SeriaLuck == nil || got.SeriaLuck.Before != 4 || got.SeriaLuck.After != 5 {
t.Fatalf("SeriaLuck projection=%+v, want 4->5", got.SeriaLuck)
}
} else if got.SeriaLuck != nil {
t.Fatalf("ordinary box SeriaLuck projection=%+v, want nil", got.SeriaLuck)
}
if len(got.PostActions) != 0 {
t.Fatalf("post actions=%v, want no full container/combat projection", got.PostActions)
}
wantRefreshes := []alignedcmd.ItemSlotRefresh{
{ListType: listTypeMain, SlotIndex: 85},
{ListType: listTypeMain, SlotIndex: 108},
}
if !reflect.DeepEqual(got.ItemSlotRefreshes, wantRefreshes) {
t.Fatalf("slot refreshes=%v, want %v", got.ItemSlotRefreshes, wantRefreshes)
}
})
}
}
func TestMagicBoxChangedSlotRefreshesDeduplicatesReusedSourceSlot(t *testing.T) {
got := magicBoxChangedSlotRefreshes(MagicBoxResult{
Changed: true,
BoxSlotIndex: 85,
MaterialSlotIndex: 121,
Rewards: []MagicBoxGrantedReward{
{Slot: 85, ItemID: 1, Count: 1},
{Slot: 108, ItemID: 2, Count: 1},
{Slot: 108, ItemID: 2, Count: 2},
},
})
want := []alignedcmd.ItemSlotRefresh{
{ListType: listTypeMain, SlotIndex: 85},
{ListType: listTypeMain, SlotIndex: 121},
{ListType: listTypeMain, SlotIndex: 108},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("slot refreshes=%v, want %v", got, want)
}
}
func TestBuildMagicBoxSingleAckCarriesSeriaDoubleFlagBeforeSlots(t *testing.T) {
result := MagicBoxResult{
ClientType: clientMainListType,
BoxSlotIndex: 65,
MaterialSlotIndex: -1,
SeriaLuckDoubleTriggered: true,
Rewards: []MagicBoxGrantedReward{{
Slot: 82,
ItemID: 2600014,
Count: 20,
}},
}
got := buildMagicBoxSingleAck(result)
want := []byte{0x01, 0x04, 0x01, 0x41, 0x00, 0xFF, 0xFF, 0x01, 0x00}
row := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(row[0x00:0x02], 82)
binary.LittleEndian.PutUint32(row[0x02:0x06], 2600014)
binary.LittleEndian.PutUint32(row[0x06:0x0A], 20)
want = append(want, row...)
if string(got) != string(want) {
t.Fatalf("single seria ack = %x, want %x", got, want)
}
if got, want := len(got), 9+currentMagicBoxEntrySize; got != want {
t.Fatalf("single seria ack len = %d, want %d", got, want)
}
}
func TestBuildMagicBoxSingleAckCarriesZeroDoubleFlagForOrdinarySeriaOpen(t *testing.T) {
result := MagicBoxResult{
ClientType: clientMainListType,
BoxSlotIndex: 85,
MaterialSlotIndex: -1,
Rewards: []MagicBoxGrantedReward{{
Slot: 108,
ItemID: 10000391,
Count: 2,
}},
}
got := buildMagicBoxSingleAck(result)
want := []byte{0x01, 0x04, 0x00, 0x55, 0x00, 0xFF, 0xFF, 0x01, 0x00}
row := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(row[0x00:0x02], 108)
binary.LittleEndian.PutUint32(row[0x02:0x06], 10000391)
binary.LittleEndian.PutUint32(row[0x06:0x0A], 2)
want = append(want, row...)
if string(got) != string(want) {
t.Fatalf("ordinary seria ack = %x, want %x", got, want)
}
if got, want := len(got), 9+currentMagicBoxEntrySize; got != want {
t.Fatalf("ordinary seria ack len = %d, want %d", got, want)
}
}
func TestHandlerMagicBoxSingleSeriaUsesCurrentEXECase4Header(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, map[string]dnfrepo.ItemStack{
"0:85": {
ItemID: 2682272,
Count: 1,
Extra: map[string]string{"item_kind": "stackable"},
},
})
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc",
Metadata: map[string]string{currentSeriaLuckMetadataKey: "0"},
}); err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 10000391, Weight: 100, Count: 2}}}},
}
got := handleMagicBoxRequest(
t,
repos,
staticMagicBoxResolver(resolution),
staticMagicBoxRewardResolver(map[int64]alignedcmd.MagicBoxRewardItem{
10000391: {ItemID: 10000391, Kind: "stackable", SlotStart: 65, SlotEnd: 120},
}),
[]byte{0x04, 0x55, 0x00, 0xFF, 0xFF},
)
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if got.SeriaLuck == nil || got.SeriaLuck.Before != 0 || got.SeriaLuck.After != 1 {
t.Fatalf("SeriaLuck projection=%+v, want 0->1 after one open", got.SeriaLuck)
}
ack := got.UpperResponses[0]
want := []byte{0x01, 0x04, 0x00, 0x55, 0x00, 0xFF, 0xFF, 0x01, 0x00}
row := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(row[0x00:0x02], 65)
binary.LittleEndian.PutUint32(row[0x02:0x06], 10000391)
binary.LittleEndian.PutUint32(row[0x06:0x0A], 2)
want = append(want, row...)
if string(ack.Body) != string(want) {
t.Fatalf("single seria case4 ack = %x, want %x", ack.Body, want)
}
}
func TestHandlerMagicBoxFailureAcksZeroByte(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxBaseSlots())
got := handleMagicBoxRequest(t, repos, staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()), magicBoxRequestBody(99, 121))
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
t.Fatalf("result = %+v", got)
}
if string(got.UpperResponses[0].Body) != string([]byte{0x00}) {
t.Fatalf("failure ack = %x, want {00}", got.UpperResponses[0].Body)
}
}
func TestHandlerMagicBoxParseFailureFailsClosed(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
got := handleMagicBoxRequest(t, repos, staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()), []byte{9})
if !got.Handled {
t.Fatalf("result = %+v", got)
}
if got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("parse failure must fail closed without any response: %+v", got)
}
}
func TestHandlerMagicBoxNilResolversFailClosed(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
got := handleMagicBoxRequest(t, repos, nil, nil, magicBoxRequestBody(10, 121))
if !got.Handled {
t.Fatalf("result = %+v", got)
}
if got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("nil resolvers must fail closed without any response: %+v", got)
}
}
@@ -0,0 +1,132 @@
package packageitem
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeSelectablePackageRequest(t *testing.T) {
got, err := DecodeSelectablePackageRequest([]byte{0x05, 0x00, 0x00, 0x00, 0x44, 0x33, 0x22, 0x11, 0x01})
if err != nil {
t.Fatalf("DecodeSelectablePackageRequest error = %v", err)
}
if got.SlotIndex != 5 || got.SelectedItemTemplateID != 0x11223344 || got.SelectionFlag != 1 {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeMagicBoxSingleRequest(t *testing.T) {
got, err := DecodeMagicBoxSingleRequest([]byte{clientMainListType, 0x06, 0x00, 0x07, 0x00})
if err != nil {
t.Fatalf("DecodeMagicBoxSingleRequest error = %v", err)
}
if got.ListType != listTypeMain || got.SlotIndex != 6 || got.MaterialSlotIndex != 7 {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksPackageAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseBoosterItem),
Body: []byte{0x05, 0x00, 0x00, 0x00, 0x44, 0x33, 0x22, 0x11, 0x01},
AccountID: " acc-package ",
SelectedCharacterID: 81,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if !strings.Contains(got.Reason, `account="acc-package"`) || !strings.Contains(got.Reason, "char=81") || !strings.Contains(got.Reason, "slot=5") || !strings.Contains(got.Reason, "selected=287454020") {
t.Fatalf("reason should include command plan, got %q", got.Reason)
}
}
func TestHandlerSelectableUsesOwnerButStillBlocksAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "81",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseBoosterItem),
Body: []byte{0x05, 0x00, 0x00, 0x00, 0x44, 0x33, 0x22, 0x11, 0x01},
AccountID: " acc-package ",
SelectedCharacterID: 81,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"package owner 已验证", "source=(0,5)", "item=7001", "selected=287454020", "禁止回成功包"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason missing %q: %s", want, got.Reason)
}
}
}
func TestHandlerMagicBoxUsesOwnerButStillBlocksAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "82",
Slots: map[string]dnfrepo.ItemStack{
"0:6": {ItemID: 8001, Count: 1},
"0:7": {ItemID: 8002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUseRandomboxItem),
Body: []byte{clientMainListType, 0x06, 0x00, 0x07, 0x00},
AccountID: " acc-package ",
SelectedCharacterID: 82,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"resolvers unavailable", "magic box"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason missing %q: %s", want, got.Reason)
}
}
}
func TestSelectableCommandRecordsRewardOwnerGap(t *testing.T) {
cmd := NewSelectableCommand(alignedcmd.Request{
AccountID: " acc-package-2 ",
SelectedCharacterID: 82,
}, SelectablePackageRequest{
SlotIndex: 5,
SelectionContext: 1,
SelectedItemTemplateID: 1001,
SelectionFlag: 1,
AvatarChoiceCount: 2,
})
if cmd.AccountID != "acc-package-2" || cmd.SelectedCharacterID != 82 || cmd.SlotIndex != 5 || cmd.SelectedItemTemplateID != 1001 || cmd.AvatarChoiceCount != 2 {
t.Fatalf("cmd = %+v", cmd)
}
if !strings.Contains(cmd.String(), "popup/NOTI order") {
t.Fatalf("command plan must name popup/order gap: %s", cmd.String())
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,537 @@
package packageitem
import (
"context"
"errors"
"strconv"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func staticMagicBoxResolver(resolution alignedcmd.MagicBoxResolution) alignedcmd.MagicBoxResolver {
return func(boxItemID int64) (alignedcmd.MagicBoxResolution, error) {
return resolution, nil
}
}
func staticMagicBoxRewardResolver(items map[int64]alignedcmd.MagicBoxRewardItem) alignedcmd.MagicBoxRewardItemResolver {
return func(itemID int64) (alignedcmd.MagicBoxRewardItem, error) {
return items[itemID], nil
}
}
func magicBoxTestCommand(boxSlot int16, materialSlot int16) MagicBoxCommand {
return MagicBoxCommand{
SelectedCharacterID: 77,
RawListType: 0,
ListType: listTypeMain,
SlotIndex: boxSlot,
MaterialSlotIndex: materialSlot,
}
}
func magicBoxTestResolution() alignedcmd.MagicBoxResolution {
return alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{
{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}}},
{DrawCount: 1, Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2682272, Weight: 100, Count: 1}}},
},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
BoxPVFPath: "stackable/ect/chn_random/chn_amazingbox_10007368.stk",
}
}
func magicBoxTestRewardItems() map[int64]alignedcmd.MagicBoxRewardItem {
return map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", StackLimit: 0, SlotStart: 65, SlotEnd: 120, PVFPath: "stackable/professional/potion/ptn_instantmovement.stk"},
2682272: {ItemID: 2682272, Kind: "stackable", StackLimit: 1, SlotStart: 65, SlotEnd: 120, Seal: true, PVFPath: "stackable/ect/chn_random/chn_blessed_box.stk"},
}
}
func saveMagicBoxFixture(t *testing.T, ctx context.Context, repos dnfrepo.Group, slots map[string]dnfrepo.ItemStack) {
t.Helper()
if _, found, err := repos.Account.Load(ctx, "acc"); err != nil {
t.Fatalf("load account: %v", err)
} else if !found {
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc", State: "active"}); err != nil {
t.Fatalf("save account: %v", err)
}
}
character, found, err := repos.Character.Load(ctx, "77")
if err != nil {
t.Fatalf("load character: %v", err)
}
if !found {
character = dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Name: "magic-box", Level: 90}
} else if character.AccountID == "" {
character.AccountID = "acc"
}
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots}); err != nil {
t.Fatalf("save inventory: %v", err)
}
}
func magicBoxBaseSlots() map[string]dnfrepo.ItemStack {
boxRaw := make([]byte, currentMagicBoxEntrySize)
boxRaw[0x06] = 1
hammerRaw := make([]byte, currentMagicBoxEntrySize)
hammerRaw[0x06] = 3
return map[string]dnfrepo.ItemStack{
"0:10": {ItemID: 10007368, Count: 1, RawEntry: boxRaw, Extra: map[string]string{"item_kind": "stackable"}},
"0:121": {ItemID: 10007367, Count: 3, RawEntry: hammerRaw, Extra: map[string]string{"item_kind": "stackable"}},
}
}
func TestOwnerApplyMagicBoxConsumesAndGrants(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxBaseSlots())
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBox error = %v", err)
}
if !result.Success || !result.Changed || len(result.Rewards) != 2 {
t.Fatalf("result = %+v", result)
}
if result.Rewards[0].ItemID != 2600014 || result.Rewards[0].Count != 2 || result.Rewards[1].ItemID != 2682272 || result.Rewards[1].Count != 1 {
t.Fatalf("rewards = %+v", result.Rewards)
}
loaded, ok, err := repos.Inventory.Load(ctx, "77")
if err != nil || !ok {
t.Fatalf("load inventory ok=%t err=%v", ok, err)
}
if _, found := loaded.Slots["0:10"]; found {
t.Fatalf("consumed box slot should be deleted")
}
hammer := loaded.Slots["0:121"]
if hammer.Count != 2 || hammer.RawEntry[0x06] != 2 {
t.Fatalf("hammer = %+v, want count 2 with raw sync", hammer)
}
potion, found := loaded.Slots["0:65"]
if !found || potion.ItemID != 2600014 || potion.Count != 2 {
t.Fatalf("potion grant = %+v found=%t", potion, found)
}
seria, found := loaded.Slots["0:66"]
if !found || seria.ItemID != 2682272 || seria.Count != 1 || seria.Extra["seal_flag"] != "1" {
t.Fatalf("seria grant = %+v found=%t", seria, found)
}
}
func TestOwnerApplyMagicBoxRoutesCreatureRewardToPetInventory(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveMagicBoxFixture(t, ctx, repos, magicBoxBaseSlots())
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{
DrawCount: 1,
Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 702, Weight: 100, Count: 1}},
}},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
}
reward := alignedcmd.MagicBoxRewardItem{
ItemID: 702,
Kind: "equipment",
TargetListType: 7,
EquipmentType: "[creature]",
StackLimit: 1,
SlotStart: 0,
SlotEnd: 139,
PVFPath: "equipment/creature/pet.equ",
}
result, err := owner.ApplyMagicBox(
ctx,
magicBoxTestCommand(10, 121),
staticMagicBoxResolver(resolution),
staticMagicBoxRewardResolver(map[int64]alignedcmd.MagicBoxRewardItem{702: reward}),
)
if err != nil {
t.Fatal(err)
}
if !result.Success || len(result.Rewards) != 1 || result.Rewards[0].Slot != 0 {
t.Fatalf("result=%+v", result)
}
loaded, found, err := repos.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
pet := loaded.Slots["7:0"]
if pet.ItemID != 702 || pet.Count != 1 || pet.Extra["equipment_type"] != "[creature]" {
t.Fatalf("pet reward=%+v", pet)
}
for key, stack := range loaded.Slots {
if key != "7:0" && stack.ItemID == 702 {
t.Fatalf("creature reward leaked into ordinary inventory key=%s stack=%+v", key, stack)
}
}
}
func TestOwnerApplyMagicBoxMergesExistingStacks(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:4"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 1, Extra: map[string]string{"item_kind": "stackable"}}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBox error = %v", err)
}
if !result.Success {
t.Fatalf("result = %+v", result)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
quick := loaded.Slots["0:4"]
if quick.Count != 3 {
t.Fatalf("quick-slot stack = %+v, want merged count 3", quick)
}
}
// 继承的 expire=0 老行不能被限时奖励改写;按"一个容器里同一道具 id 只能有一行"
// 的规则,限时奖励也不再另开一行,而是整行走本路径既有的溢出邮件。
func TestOwnerApplyMagicBoxTimedRewardOverflowsInsteadOfDuplicatingRow(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:65"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 1, Extra: map[string]string{"item_kind": "stackable"}}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
expireAt := time.Unix(1_900_000_000, 0).UTC()
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{
DrawCount: 1,
Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}},
}},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
}
rewards := map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {
ItemID: 2600014,
Kind: "stackable",
StackLimit: 100,
SlotStart: 65,
SlotEnd: 120,
ExpireAt: expireAt,
UsablePeriodDays: 30,
},
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(rewards))
if err != nil {
t.Fatal(err)
}
if !result.Success {
t.Fatalf("result=%+v", result)
}
loaded, found, err := repos.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
legacy := loaded.Slots["0:65"]
if legacy.Count != 1 || magicBoxStackExpireUnix(legacy) != 0 {
t.Fatalf("legacy stack was mutated: %+v", legacy)
}
for key, stack := range loaded.Slots {
if stack.ItemID == 2600014 && key != "0:65" {
t.Fatalf("timed reward opened a second row at %s: %+v", key, stack)
}
}
if result.OverflowMailID == "" {
t.Fatalf("timed reward must go to overflow mail: %+v", result)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
wantUnix := strconv.FormatInt(expireAt.Unix(), 10)
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2600014 || mail.Attachments[0].Count != 2 ||
!mail.Attachments[0].ExpireAt.Equal(expireAt) || mail.Attachments[0].Extra["expire_time"] != wantUnix ||
mail.Attachments[0].Extra["usable_period_days"] != "30" {
t.Fatalf("overflow mail = %+v", mail)
}
}
// 还有额度时必须并进现有那一行,并把容器刚好填到上限,剩下的才进溢出邮件。
func TestOwnerApplyMagicBoxMergesIntoExistingRowUpToContainerLimit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:65"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 90, Extra: map[string]string{"item_kind": "stackable"}}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{
DrawCount: 1,
Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 20}},
}},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
}
rewards := map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", StackLimit: 100, SlotStart: 65, SlotEnd: 120},
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(rewards))
if err != nil {
t.Fatal(err)
}
if !result.Success {
t.Fatalf("result=%+v", result)
}
loaded, _, err := repos.Inventory.Load(ctx, "77")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if stack := loaded.Slots["0:65"]; stack.ItemID != 2600014 || stack.Count != 100 {
t.Fatalf("merged stack = %+v, want the existing row filled to 100", stack)
}
for key, stack := range loaded.Slots {
if stack.ItemID == 2600014 && key != "0:65" {
t.Fatalf("reward opened a second row at %s: %+v", key, stack)
}
}
if result.OverflowMailID == "" {
t.Fatalf("remainder must go to overflow mail: %+v", result)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2600014 || mail.Attachments[0].Count != 10 {
t.Fatalf("overflow mail = %+v", mail)
}
}
// [stack limit] 是"这个容器里该道具的总量":超出容器上限的部分不能再开第二行,
// 整行走系统邮件。
func TestOwnerApplyMagicBoxCapsGrantByContainerStackLimit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:65"] = dnfrepo.ItemStack{ItemID: 2600014, Count: 2, Extra: map[string]string{"item_kind": "stackable"}}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{
DrawCount: 1,
Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}},
}},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
}
rewards := map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", StackLimit: 3, SlotStart: 65, SlotEnd: 120},
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(rewards))
if err != nil {
t.Fatal(err)
}
if !result.Success {
t.Fatalf("result = %+v", result)
}
loaded, _, _ := repos.Inventory.Load(ctx, "77")
if first := loaded.Slots["0:65"]; first.ItemID != 2600014 || first.Count != 3 {
t.Fatalf("capped merge first=%+v", first)
}
for key, stack := range loaded.Slots {
if stack.ItemID == 2600014 && key != "0:65" {
t.Fatalf("capped grant opened a second row at %s: %+v", key, stack)
}
}
if result.OverflowMailID == "" {
t.Fatalf("capped remainder must go to overflow mail: %+v", result)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2600014 || mail.Attachments[0].Count != 1 {
t.Fatalf("overflow mail = %+v", mail)
}
}
func TestOwnerApplyMagicBoxRoutesMainInventoryOverflowToSystemMail(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
slots["0:65"] = dnfrepo.ItemStack{ItemID: 9999, Count: 1}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
resolution := alignedcmd.MagicBoxResolution{
Kind: "random",
Groups: []alignedcmd.MagicBoxRewardGroup{{
DrawCount: 1,
Entries: []alignedcmd.MagicBoxRewardEntry{{ItemID: 2600014, Weight: 100, Count: 2}},
}},
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
}
rewards := map[int64]alignedcmd.MagicBoxRewardItem{
2600014: {ItemID: 2600014, Kind: "stackable", SlotStart: 65, SlotEnd: 65, PVFPath: "stackable/test/reward.stk"},
}
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), staticMagicBoxResolver(resolution), staticMagicBoxRewardResolver(rewards))
if err != nil {
t.Fatalf("ApplyMagicBox: %v", err)
}
if !result.Success || result.OverflowMailID == "" || len(result.Rewards) != 0 {
t.Fatalf("result = %+v", result)
}
loaded, _, err := repos.Inventory.Load(ctx, "77")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if _, found := loaded.Slots["0:10"]; found || loaded.Slots["0:121"].Count != 2 {
t.Fatalf("source consumption did not commit with overflow: %+v", loaded.Slots)
}
if stack := loaded.Slots["0:65"]; stack.ItemID != 9999 || stack.Count != 1 {
t.Fatalf("full target slot changed: %+v", stack)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2600014 || mail.Attachments[0].Count != 2 ||
mail.Attachments[0].Extra["pvf_path"] != "stackable/test/reward.stk" {
t.Fatalf("overflow mail = %+v", mail)
}
if mail.SenderName != "系统" || mail.Title != "背包已满:礼盒奖励" ||
mail.Body != "背包空间不足,礼盒奖励已通过邮件发送。请清理对应道具分页后领取。" {
t.Fatalf("overflow mail prompt = %+v", mail)
}
}
func TestOwnerApplyMagicBoxRejections(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
cmd MagicBoxCommand
resolver alignedcmd.MagicBoxResolution
setup func(slots map[string]dnfrepo.ItemStack)
}{
{
name: "box slot missing",
cmd: magicBoxTestCommand(99, 121),
resolver: magicBoxTestResolution(),
},
{
name: "material missing",
cmd: magicBoxTestCommand(10, 121),
resolver: magicBoxTestResolution(),
setup: func(slots map[string]dnfrepo.ItemStack) {
delete(slots, "0:121")
},
},
{
name: "not an openable box",
cmd: magicBoxTestCommand(10, 121),
resolver: alignedcmd.MagicBoxResolution{
Kind: "",
BoxPVFPath: "stackable/other/plain.stk",
},
},
{
name: "empty reward pool",
cmd: magicBoxTestCommand(10, 121),
resolver: alignedcmd.MagicBoxResolution{
Kind: "random",
MaterialItemID: 10007367,
MaterialCountPerUse: 1,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
if tc.setup != nil {
tc.setup(slots)
}
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyMagicBox(ctx, tc.cmd, staticMagicBoxResolver(tc.resolver), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBox error = %v", err)
}
if result.Success || result.Changed {
t.Fatalf("result = %+v, want rejected without mutation", result)
}
if result.Reason == "" {
t.Fatalf("rejection must carry a reason")
}
})
}
}
func TestOwnerApplyMagicBoxMaterialSlotFallback(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
slots := magicBoxBaseSlots()
saveMagicBoxFixture(t, ctx, repos, slots)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
// Material slot points at the box itself; the owner must fall back to an
// id-based scan, matching the 86JP slot-mismatch fallback.
result, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 10), staticMagicBoxResolver(magicBoxTestResolution()), staticMagicBoxRewardResolver(magicBoxTestRewardItems()))
if err != nil {
t.Fatalf("ApplyMagicBox error = %v", err)
}
if !result.Success || result.MaterialSlotIndex != 121 {
t.Fatalf("result = %+v, want fallback material slot 121", result)
}
}
func TestOwnerApplyMagicBoxNilResolversFailClosed(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
if _, err := owner.ApplyMagicBox(ctx, magicBoxTestCommand(10, 121), nil, nil); !errors.Is(err, ErrMagicBoxResolverRequired) {
t.Fatalf("ApplyMagicBox error = %v, want ErrMagicBoxResolverRequired", err)
}
}
@@ -0,0 +1,97 @@
package packageitem
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlanSelectableReadsSourceSlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "81",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 7001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanSelectable(ctx, SelectableCommand{
SelectedCharacterID: 81,
SlotIndex: 5,
SelectedItemTemplateID: 9001,
})
if err != nil {
t.Fatalf("PlanSelectable() error = %v", err)
}
if got.SourceItemID != 7001 || got.SelectedItemTemplateID != 9001 || got.SourceSlotIndex != 5 {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMagicBoxChecksMaterialSlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "82",
Slots: map[string]dnfrepo.ItemStack{
"0:6": {ItemID: 8001, Count: 1},
"0:7": {ItemID: 8002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanMagicBox(ctx, MagicBoxCommand{
SelectedCharacterID: 82,
ListType: listTypeMain,
SlotIndex: 6,
MaterialSlotIndex: 7,
})
if err != nil {
t.Fatalf("PlanMagicBox() error = %v", err)
}
if got.SourceItemID != 8001 || got.MaterialItemID != 8002 || got.MaterialSlotIndex != 7 {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMagicBoxRejectsMissingMaterial(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "83",
Slots: map[string]dnfrepo.ItemStack{
"0:6": {ItemID: 8001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.PlanMagicBox(ctx, MagicBoxCommand{
SelectedCharacterID: 83,
ListType: listTypeMain,
SlotIndex: 6,
MaterialSlotIndex: 7,
})
if !errors.Is(err, ErrMaterialNotFound) {
t.Fatalf("PlanMagicBox() error = %v, want ErrMaterialNotFound", err)
}
}
@@ -0,0 +1,92 @@
package packageitem
import (
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 现场 2026-09-27(线上 203.0.113.11):
//
// dnf_inventory_items 里 equipment/character/thief/weapon/twinsword/109010047.equ
// (`传承 : 旭光精灵的碧岩双剑`)被写成了一行 count=2/4/5/6/8 的装备行,
// extra 全部是魔盒发放写的那几个键(item_kind/pvf_path/quality_seed=999999998/
// durability/max_durability/equipment_type/seal_flag),没有 source。
// 该武器 [price] 100800 / [value] 80640,NPC 出售每次付 16128 金币,而 0:37
// 这一格在 packet_log 里被连续卖了 29 次(game-000527,13:27:10~13:27:25)——
// 客户端装备行不显示数量,每卖一次服务端只扣 1(npc_shop_projection.go 里
// equipment 恒 applied=1),行还在原处,玩家看到的就是“卖掉又回到背包”。
//
// 本用例钉住根因:魔盒发奖的“并入已有行”分支必须和新建行分支一样排除装备。
// 装备每件一行,绝不允许并进同 id 的已有行。
func TestMagicBoxEquipmentRewardNeverMergesIntoExistingEquipmentRow(t *testing.T) {
const weapon = int64(109010047)
items := map[string]dnfrepo.ItemStack{
// 快捷栏 3..8 上的同一件装备(fill 分支优先扫这一段)。
"0:7": {ItemID: weapon, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
}
reward := alignedcmd.MagicBoxRewardItem{
ItemID: weapon,
Kind: "equipment",
TargetListType: listTypeMain,
EquipmentType: "[weapon]",
// 真实武器 .equ 没有 [stack limit],所以这里是 0(不限量)——
// 旧实现的唯一护栏是 reward.StackLimit == 1,对 0 不生效。
StackLimit: 0,
SlotStart: 9,
SlotEnd: 48,
PVFPath: "equipment/character/thief/weapon/twinsword/109010047.equ",
}
remaining, _, err := grantMagicBoxReward(items, reward, 3)
if err != nil {
t.Fatalf("grantMagicBoxReward error = %v", err)
}
if remaining != 0 {
t.Fatalf("remaining = %d, want 0 (3 件都必须落地)", remaining)
}
if got := items["0:7"].Count; got != 1 {
t.Fatalf("快捷栏已有装备行被并成 %d 件;装备必须每件一行(现场 bug:客户端不显示装备数量,卖一件行还在,等同“无限卖钱”)", got)
}
// 3 件装备 = 3 行:原有 1 行 + 新开的 3 行里的… 实际是 3 件各自一行。
rows := 0
for key, stack := range items {
if stack.ItemID != weapon {
t.Fatalf("意外道具 %s=%+v", key, stack)
}
if stack.Count != 1 {
t.Fatalf("%s count=%d,装备行必须恒为 1", key, stack.Count)
}
rows++
}
if rows != 4 {
t.Fatalf("装备行数 = %d, want 4(原有 1 + 新发 3)", rows)
}
}
// 对照组:可叠加道具沿用旧行为(并进已有行),证明修复不会误伤 stackable。
func TestMagicBoxStackableRewardStillTopsUpExistingRow(t *testing.T) {
items := map[string]dnfrepo.ItemStack{
"0:7": {ItemID: 2600014, Count: 5, Extra: map[string]string{"item_kind": "stackable"}},
}
reward := alignedcmd.MagicBoxRewardItem{
ItemID: 2600014,
Kind: "stackable",
TargetListType: listTypeMain,
StackLimit: 0,
SlotStart: 121,
SlotEnd: 176,
PVFPath: "stackable/consumption/test.stk",
}
remaining, _, err := grantMagicBoxReward(items, reward, 3)
if err != nil {
t.Fatalf("grantMagicBoxReward error = %v", err)
}
if remaining != 0 {
t.Fatalf("remaining = %d, want 0", remaining)
}
if got := items["0:7"].Count; got != 8 {
t.Fatalf("可叠加道具就没有并进已有行:count=%d, want 8", got)
}
}
@@ -0,0 +1,152 @@
// 本文件解析礼包和随机盒子 C2S 请求体。
// 可选礼包按 C# SelectablePackageOpenRequest,随机盒子单开按 MagicBoxOpenRequest.TryParseSingle。
package packageitem
import (
"encoding/binary"
"fmt"
)
const (
listTypeMain byte = 0
clientMainListType byte = 4
)
// SelectablePackageRequest 描述可选礼包开启请求。
type SelectablePackageRequest struct {
SlotIndex int16
SelectionContext int16
SelectedItemTemplateID int32
SelectionFlag byte
AvatarChoiceCount int
}
// MagicBoxSingleRequest 描述随机盒子单开请求。
type MagicBoxSingleRequest struct {
RawListType byte
ListType byte
SlotIndex int16
MaterialSlotIndex int16
}
// MagicBoxExpandRequest 描述 0x0468 随机盒子连开(全部开启)请求。
// 布局来自当前 NoPack 客户端实测抓包(2026-07-25,开 100 个泰迪礼盒):
// u8 listType, u16 boxSlot, u32 boxItemID, u16 materialSlot,
// u32 materialItemID, u16 openCount,共 15 字节。
type MagicBoxExpandRequest struct {
RawListType byte
ListType byte
SlotIndex int16
BoxItemID uint32
MaterialSlotIndex int16
MaterialItemID uint32
OpenCount uint16
}
// DecodeSelectablePackageRequest 解析 0x00A0 可选礼包请求。
func DecodeSelectablePackageRequest(body []byte) (SelectablePackageRequest, error) {
if len(body) < 9 {
return SelectablePackageRequest{}, fmt.Errorf("body too short: got %d want >= 9", len(body))
}
req := SelectablePackageRequest{
SlotIndex: int16(binary.LittleEndian.Uint16(body)),
SelectionContext: int16(binary.LittleEndian.Uint16(body[2:])),
SelectedItemTemplateID: int32(binary.LittleEndian.Uint32(body[4:])),
SelectionFlag: body[8],
}
if req.SelectedItemTemplateID <= 0 {
return SelectablePackageRequest{}, fmt.Errorf("selected item %d invalid", req.SelectedItemTemplateID)
}
req.AvatarChoiceCount = countAvatarChoices(body)
return req, nil
}
// DecodeMagicBoxSingleRequest 解析 0x00D0 随机盒子单开请求。
func DecodeMagicBoxSingleRequest(body []byte) (MagicBoxSingleRequest, error) {
if len(body) < 3 {
return MagicBoxSingleRequest{}, fmt.Errorf("body too short: got %d want >= 3", len(body))
}
listType, ok := mapMagicBoxListType(body[0])
if !ok {
return MagicBoxSingleRequest{}, fmt.Errorf("list type %d invalid", body[0])
}
req := MagicBoxSingleRequest{
RawListType: body[0],
ListType: listType,
SlotIndex: int16(binary.LittleEndian.Uint16(body[1:])),
MaterialSlotIndex: -1,
}
if req.SlotIndex < 0 {
return MagicBoxSingleRequest{}, fmt.Errorf("slot %d invalid", req.SlotIndex)
}
if len(body) >= 5 {
req.MaterialSlotIndex = int16(binary.LittleEndian.Uint16(body[3:]))
if req.MaterialSlotIndex < 0 {
req.MaterialSlotIndex = -1
}
}
return req, nil
}
// DecodeMagicBoxExpandRequest 解析 0x0468 随机盒子连开请求。
func DecodeMagicBoxExpandRequest(body []byte) (MagicBoxExpandRequest, error) {
if len(body) < 15 {
return MagicBoxExpandRequest{}, fmt.Errorf("body too short: got %d want >= 15", len(body))
}
listType, ok := mapMagicBoxListType(body[0])
if !ok {
return MagicBoxExpandRequest{}, fmt.Errorf("list type %d invalid", body[0])
}
req := MagicBoxExpandRequest{
RawListType: body[0],
ListType: listType,
SlotIndex: int16(binary.LittleEndian.Uint16(body[1:])),
BoxItemID: binary.LittleEndian.Uint32(body[3:]),
MaterialSlotIndex: int16(binary.LittleEndian.Uint16(body[7:])),
MaterialItemID: binary.LittleEndian.Uint32(body[9:]),
OpenCount: binary.LittleEndian.Uint16(body[13:]),
}
if req.SlotIndex < 0 {
return MagicBoxExpandRequest{}, fmt.Errorf("slot %d invalid", req.SlotIndex)
}
if req.BoxItemID == 0 {
return MagicBoxExpandRequest{}, fmt.Errorf("box item %d invalid", req.BoxItemID)
}
if req.MaterialSlotIndex < 0 {
req.MaterialSlotIndex = -1
}
if req.OpenCount == 0 {
return MagicBoxExpandRequest{}, fmt.Errorf("open count %d invalid", req.OpenCount)
}
return req, nil
}
func countAvatarChoices(body []byte) int {
for count := 1; count <= 32; count++ {
countOffset := 4 + count*4
expectedLength := countOffset + 1 + count*5
if expectedLength == len(body) && body[countOffset] == byte(count) {
return count
}
}
return 0
}
func mapMagicBoxListType(raw byte) (byte, bool) {
if raw == listTypeMain || raw == clientMainListType {
return listTypeMain, true
}
return raw, false
}
func (r SelectablePackageRequest) String() string {
return fmt.Sprintf("slot=%d context=%d selected=%d flag=%d avatarChoices=%d", r.SlotIndex, r.SelectionContext, r.SelectedItemTemplateID, r.SelectionFlag, r.AvatarChoiceCount)
}
func (r MagicBoxSingleRequest) String() string {
return fmt.Sprintf("list=%d slot=%d materialSlot=%d", r.ListType, r.SlotIndex, r.MaterialSlotIndex)
}
func (r MagicBoxExpandRequest) String() string {
return fmt.Sprintf("list=%d slot=%d box=%d materialSlot=%d material=%d count=%d", r.ListType, r.SlotIndex, r.BoxItemID, r.MaterialSlotIndex, r.MaterialItemID, r.OpenCount)
}
@@ -0,0 +1,62 @@
package packageitem
import (
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
// A 赛丽亚盒子 that rolls revive coins must fold them into the fixed slot-1
// wallet. Left as an ordinary consumable row the player sees two counters --
// the old wallet total and the new stack -- and right-clicking the stack
// falls through to the experience/SP branch ("缺少可增加的经验值/SP"), until
// the next character select consolidates it.
func TestMagicBoxReviveCoinRewardGoesToTheWallet(t *testing.T) {
for _, itemID := range []int64{1, 42} {
items := map[string]dnfrepo.ItemStack{
dnfrevivecoin.WalletKey(): {ItemID: 1, Count: 100},
}
remaining, stacks, err := grantMagicBoxReward(items, alignedcmd.MagicBoxRewardItem{
ItemID: itemID,
Kind: "stackable",
TargetListType: listTypeMain,
SlotStart: 121,
SlotEnd: 176,
PVFPath: "stackable/cash/coin_general.stk",
}, 10)
if err != nil || remaining != 0 || len(stacks) != 0 {
t.Fatalf("item %d: remaining=%d stacks=%d err=%v", itemID, remaining, len(stacks), err)
}
wallet := items[dnfrevivecoin.WalletKey()]
if wallet.ItemID != dnfrevivecoin.WalletItemID || wallet.Count != 110 {
t.Fatalf("item %d: wallet=%+v want item 1 x110", itemID, wallet)
}
if len(items) != 1 {
t.Fatalf("item %d: a loose consumable row was left behind: %+v", itemID, items)
}
// The result popup still has to name a slot for the reward row.
if slot := magicBoxRewardSlot(items, itemID); slot != dnfrevivecoin.WalletSlot {
t.Fatalf("item %d: reward slot=%d want %d", itemID, slot, dnfrevivecoin.WalletSlot)
}
}
}
// An ordinary box reward keeps the PVF slot-range placement.
func TestMagicBoxOrdinaryRewardStillUsesTheBag(t *testing.T) {
items := map[string]dnfrepo.ItemStack{}
remaining, _, err := grantMagicBoxReward(items, alignedcmd.MagicBoxRewardItem{
ItemID: 3166,
Kind: "stackable",
TargetListType: listTypeMain,
SlotStart: 121,
SlotEnd: 176,
}, 5)
if err != nil || remaining != 0 {
t.Fatalf("remaining=%d err=%v", remaining, err)
}
if stack := items["0:121"]; stack.ItemID != 3166 || stack.Count != 5 {
t.Fatalf("bag row=%+v want 3166 x5", stack)
}
}