DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,302 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 领取邮件附件不能落进角色还没解锁的背包格:活体 2026-09-15 出现过附件被塞进
|
||||
// 未购买的行里,玩家在客户端连点都点不到。
|
||||
//
|
||||
// 窗口口径与 inventory.moveDestinationUnlockedSlotRange / dnfbridge.
|
||||
// currentMainInventoryUnlockedEnd 完全一致:主背包标准分类 7 行 × 8 格 = 56 格,
|
||||
// 前 32 格随角色解锁,之后按 CharacterContainerState.MainSlotCount(0/8/16/24) 逐行开放,
|
||||
// 所以"最后一个已解锁格" = PVF 范围起点 + 32 - 1 + MainSlotCount,上界再 +1。
|
||||
func TestClaimUnlockedMainSlotsWindowMatchesInventoryOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// 材料分类 65..120:65 + 32 - 1 + MainSlotCount 是最后一格,上界为 +1。
|
||||
material := alignedcmd.MailboxItemResolution{
|
||||
Kind: "stackable", StackableType: "[material]",
|
||||
SlotStart: 65, SlotEnd: 120, StackLimit: 1,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mainSlotCount string
|
||||
want int16
|
||||
why string
|
||||
}{
|
||||
{"no expansion", "0", 97, "默认 4 行 ⇒ 最后一格 96"},
|
||||
{"one row bought", "8", 105, "5 行 ⇒ 最后一格 104"},
|
||||
{"two rows bought", "16", 113, "6 行 ⇒ 最后一格 112"},
|
||||
{"three rows bought", "24", 121, "7 行 ⇒ 最后一格 120(整个 PVF 范围)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
owner, _ := newMailClaimSlotTestOwner(t, ctx, tc.mainSlotCount)
|
||||
if got := owner.claimUnlockedMainSlots(ctx, "77", material); got != tc.want {
|
||||
t.Fatalf("main_list_param16=%s: bound=%d want=%d (%s)", tc.mainSlotCount, got, tc.want, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fail-open:读不到容器状态、或该道具页不按行解锁(快捷栏 3..8、宠物/装扮/守护石页、
|
||||
// 固定材料 345..359 等)时必须返回 0(= 不设界、保持旧行为)。算错窗口会挡掉全部领取,
|
||||
// 比原 bug 更糟。
|
||||
func TestClaimUnlockedMainSlotsFailsOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
owner, _ := newMailClaimSlotTestOwner(t, ctx, "0")
|
||||
material := alignedcmd.MailboxItemResolution{
|
||||
Kind: "stackable", StackableType: "[material]",
|
||||
SlotStart: 65, SlotEnd: 120, StackLimit: 1,
|
||||
}
|
||||
if got := owner.claimUnlockedMainSlots(ctx, "", material); got != 0 {
|
||||
t.Fatalf("空角色 id 必须 fail-open,实际 %d", got)
|
||||
}
|
||||
// 没有容器状态行的角色:不能凭空假设 4 行(那是"发明状态")。
|
||||
bare := NewOwner(dnfrepomemory.NewMemoryGroup(), nil)
|
||||
if got := bare.claimUnlockedMainSlots(ctx, "78", material); got != 0 {
|
||||
t.Fatalf("无容器状态行必须 fail-open,实际 %d", got)
|
||||
}
|
||||
// 非 56 格区段一律不设界:快捷栏、固定材料页、宠物页、装扮页、守护石页。
|
||||
otherPages := []alignedcmd.MailboxItemResolution{
|
||||
{Kind: "stackable", SlotStart: 3, SlotEnd: 8, StackLimit: 1, PreferQuickSlots: true},
|
||||
{Kind: "stackable", SlotStart: 345, SlotEnd: 359, StackLimit: 1},
|
||||
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 7},
|
||||
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 1},
|
||||
{Kind: "equipment", SlotStart: 0, SlotEnd: 2, StackLimit: 1, ListType: 38},
|
||||
// 账号共享晶体/灵魂槽(354..365)属于账号侧 durable owner,不按角色行走。
|
||||
{Kind: "stackable", SlotStart: 354, SlotEnd: 365, StackLimit: 1},
|
||||
}
|
||||
for _, resolution := range otherPages {
|
||||
if got := owner.claimUnlockedMainSlots(ctx, "77", resolution); got != 0 {
|
||||
t.Fatalf("list=%d range=%d..%d 必须 fail-open,实际 %d",
|
||||
resolution.ListType, resolution.SlotStart, resolution.SlotEnd, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addAttachmentToInventoryBounded 的第二个参数是**排他上界**(最后一格 + 1):
|
||||
// 未解锁格既不能被占用、也不能算作可用空间。
|
||||
func TestAddAttachmentToInventoryRespectsUnlockedMainSlots(t *testing.T) {
|
||||
// 合成夹具,不依赖 PVF:主背包合法区段是 3..23,上界 8 表示"只有 3..7 已解锁"。
|
||||
resolution := alignedcmd.MailboxItemResolution{
|
||||
Kind: "stackable",
|
||||
PVFPath: "stackable/material/unlocked_slot.stk",
|
||||
StackableType: "[material]",
|
||||
SlotStart: 3,
|
||||
SlotEnd: 23,
|
||||
StackLimit: 1,
|
||||
PreferQuickSlots: false,
|
||||
}
|
||||
attachment := dnfrepo.MailAttachment{ItemID: 700, Count: 1}
|
||||
|
||||
t.Run("只落在已解锁行内", func(t *testing.T) {
|
||||
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
|
||||
changed, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(changed) != 1 || changed[0] < 3 || changed[0] >= 8 {
|
||||
t.Fatalf("落到未解锁格: changed=%v", changed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已解锁格占满时报背包已满而不是塞进未解锁格", func(t *testing.T) {
|
||||
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
|
||||
for slot := int16(3); slot < 8; slot++ {
|
||||
inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
|
||||
dnfrepo.ItemStack{ItemID: 900, Count: 1}
|
||||
}
|
||||
if _, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 8); !errors.Is(err, ErrMailboxInventoryFull) {
|
||||
t.Fatalf("err=%v want ErrMailboxInventoryFull", err)
|
||||
}
|
||||
for key, stack := range inventory.Slots {
|
||||
if stack.ItemID == 700 {
|
||||
t.Fatalf("附件仍被放进 %s", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未知解锁数时保持旧行为", func(t *testing.T) {
|
||||
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
|
||||
for slot := int16(3); slot < 8; slot++ {
|
||||
inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
|
||||
dnfrepo.ItemStack{ItemID: 900, Count: 1}
|
||||
}
|
||||
changed, err := addAttachmentToInventoryBounded(&inventory, attachment, resolution, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(changed) != 1 || changed[0] < 8 {
|
||||
t.Fatalf("未知解锁数时应允许更后面的格子: changed=%v", changed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 端到端:材料页 65..120,角色只买了 1 行扩展(main_list_param16=8,即第 5 行
|
||||
// 97..104 已解锁)。把已解锁的 65..96 填满后领取,附件必须落在 97 而不是任何地方都行;
|
||||
// 同一份背包在"0 行扩展"(只解锁 65..96)时必须报背包已满并保留附件。
|
||||
func TestMailboxExtractPlacesAttachmentInsideUnlockedRowsOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mainSlotCount string
|
||||
wantFull bool
|
||||
wantSlot int16
|
||||
}{
|
||||
{name: "one row bought places in slot 97", mainSlotCount: "8", wantSlot: 97},
|
||||
{name: "no expansion reports the bag full", mainSlotCount: "0", wantFull: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, repos := newMailClaimSlotTestOwner(t, ctx, tc.mainSlotCount)
|
||||
slots := make(map[string]dnfrepo.ItemStack)
|
||||
for slot := int16(65); slot <= 96; slot++ {
|
||||
slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)] =
|
||||
dnfrepo.ItemStack{ItemID: 9000 + int64(slot), Count: 1}
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots}); err != nil {
|
||||
t.Fatalf("save occupied unlocked rows: %v", err)
|
||||
}
|
||||
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
||||
CharacterID: "77",
|
||||
Mails: map[string]dnfrepo.MailRecord{
|
||||
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 1}}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save mailbox: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: repos,
|
||||
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 1),
|
||||
Body: currentMailIDList(1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
box, _, _ := repos.Mailbox.Load(ctx, "77")
|
||||
if tc.wantFull {
|
||||
// ackType 0x005F code 4:附件放不进去,客户端据此自己画"背包已满"。
|
||||
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
|
||||
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
|
||||
t.Fatalf("领取失败必须保留附件: %+v", box.Mails["1"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if stack, ok := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, tc.wantSlot)]; !ok || stack.ItemID != 2001 {
|
||||
t.Fatalf("附件没有落在已解锁的 %d 格: key=%v stack=%+v", tc.wantSlot, ok, stack)
|
||||
}
|
||||
for slot := int16(105); slot <= 120; slot++ {
|
||||
if _, occupied := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, slot)]; occupied {
|
||||
t.Fatalf("附件落进了未解锁的第 %d 格(只买了 1 行扩展)", slot)
|
||||
}
|
||||
}
|
||||
if !box.Mails["1"].Claimed {
|
||||
t.Fatalf("领取成功应结清整封信: %+v", box.Mails["1"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 附件并进一行**锁定**的同类堆时必须被拒绝:装备锁的语义是不可交易/强化/分解/丢弃,
|
||||
// 把锁定件数量悄悄加一等于绕过了锁。错误必须能被上层识别成"锁定",而不是"背包已满"
|
||||
// (后者会让玩家去清空背包,但问题根本不在空间)。
|
||||
func TestMailboxExtractRejectsAttachmentsMergingIntoALockedRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, repos := newMailClaimSlotTestOwner(t, ctx, "0")
|
||||
locked := dnfrepo.ItemStack{
|
||||
ItemID: 2001, Count: 1,
|
||||
Extra: map[string]string{"equipment_lock_state": "1"},
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65): locked,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save locked row: %v", err)
|
||||
}
|
||||
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
||||
CharacterID: "77",
|
||||
Mails: map[string]dnfrepo.MailRecord{
|
||||
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 1}}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save mailbox: %v", err)
|
||||
}
|
||||
resolution, err := mailboxClaimTestResolver(65, 120, 1)(2001)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve attachment: %v", err)
|
||||
}
|
||||
if _, err := addAttachmentToInventoryBounded(
|
||||
&dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
||||
mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65): locked,
|
||||
}},
|
||||
dnfrepo.MailAttachment{ItemID: 2001, Count: 1},
|
||||
resolution,
|
||||
97,
|
||||
); !errors.Is(err, ErrMailboxItemLocked) {
|
||||
t.Fatalf("err=%v want ErrMailboxItemLocked", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: repos,
|
||||
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 1),
|
||||
Body: currentMailIDList(1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
// ackType 0x005F 表里没有"锁定"这一格时用 4:客户端会自己跑一次背包检查并画提示。
|
||||
// 这里断言"确实回了失败码、而且不是 0",码值本身由 mailboxClaimErrorCode 决定。
|
||||
body := got.UpperResponses[0].Body
|
||||
if len(body) != 2 || body[0] != 0x00 || body[1] == 0 {
|
||||
t.Fatalf("锁定领取必须回非零失败码,实际 % X", body)
|
||||
}
|
||||
box, _, _ := repos.Mailbox.Load(ctx, "77")
|
||||
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
|
||||
t.Fatalf("锁定被拒必须保留附件: %+v", box.Mails["1"])
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if stack := inventory.Slots[mailboxInventorySlotKey(dnfrepo.MainInventoryListType, 65)]; stack.Count != 1 {
|
||||
t.Fatalf("锁定堆的数量被改成了 %d", stack.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// newMailClaimSlotTestOwner 建一个带容器状态行的角色 77:main_list_param16 就是
|
||||
// CharacterContainerState.MainSlotCount(0/8/16/24)。
|
||||
func newMailClaimSlotTestOwner(t *testing.T, ctx context.Context, mainSlotCount string) (Owner, dnfrepo.Group) {
|
||||
t.Helper()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
|
||||
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
|
||||
Scope: dnfrepo.CharacterContainerStateScope("77"),
|
||||
Values: map[string]string{
|
||||
"main_list_param16": mainSlotCount,
|
||||
"avatar_list_param16": "0",
|
||||
"personal_cargo_list_param16": "8",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save container state (main_list_param16=%s): %v", mainSlotCount, err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
return NewOwner(repos, nil), repos
|
||||
}
|
||||
Reference in new issue
Block a user