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,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)
}
}