Files
90CNServer/server/internal/modules/dnf/mail/claim_unlocked_slot_test.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

303 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}