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