Files
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

200 lines
8.4 KiB
Go
Raw Permalink 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"
)
// 这组回归锁定"一个容器里同一道具 id 的总量不得超过 PVF [stack limit]":
// 邮件领取只能并进现有那一行,容器里已经有该道具的行时不许再开第二行;
// 没写 [stack limit] 的道具不受这条约束。
func mailClaimStackLimitResolution(start, end, stackLimit int64) alignedcmd.MailboxItemResolution {
return alignedcmd.MailboxItemResolution{
Kind: "stackable",
PVFPath: "stackable/material/claim_limit.stk",
StackableType: "[material]",
SlotStart: int16(start),
SlotEnd: int16(end),
StackLimit: stackLimit,
PreferQuickSlots: false,
}
}
// 容器里已经有这道具的行、而且还有额度:必须并进那一行,不能另开一行。
func TestAddAttachmentMergesIntoExistingRowUnderContainerStackLimit(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: 1001, Count: 3, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 2},
mailClaimStackLimitResolution(65, 120, 1000))
if err != nil {
t.Fatalf("addAttachmentToInventory error = %v", err)
}
if len(changed) != 1 || changed[0] != 70 {
t.Fatalf("changed slots = %v, want the existing row 70", changed)
}
if len(inventory.Slots) != 1 {
t.Fatalf("claim opened a second row: %+v", inventory.Slots)
}
if stack := inventory.Slots["0:70"]; stack.Count != 5 {
t.Fatalf("merged stack = %+v, want count 5", stack)
}
}
// [stack limit] 1:容器里已经有一件时,第二件既不能另开一行也不能写进去,
// 按本路径既有规则整封拒绝(ErrMailboxInventoryFull)。
func TestAddAttachmentRefusesSecondRowForStackLimitOne(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: 1001, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 1},
mailClaimStackLimitResolution(65, 120, 1))
if !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("error = %v, want ErrMailboxInventoryFull", err)
}
if changed != nil {
t.Fatalf("changed slots = %v, want nil", changed)
}
if len(inventory.Slots) != 1 || inventory.Slots["0:70"].Count != 1 {
t.Fatalf("refused claim mutated the container: %+v", inventory.Slots)
}
}
// 容器是空的但 [stack limit] 1 只允许一件:落点最多写一件,剩下的余量由本路径
// 既有规则报 ErrMailboxInventoryFull 交给调用方(领取事务会丢弃这份候选,信留原样)。
func TestAddAttachmentCapsAmountAtContainerStackLimit(t *testing.T) {
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
changed, err := addAttachmentToInventory(&inventory, dnfrepo.MailAttachment{ItemID: 1001, Count: 2},
mailClaimStackLimitResolution(65, 120, 1))
if !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("error = %v, want ErrMailboxInventoryFull", err)
}
if changed != nil {
t.Fatalf("changed slots = %v, want nil on the refused remainder", changed)
}
if len(inventory.Slots) != 1 || inventory.Slots["0:65"].Count != 1 {
t.Fatalf("capped claim wrote container=%+v, want one row with exactly one unit", inventory.Slots)
}
}
// 同一个上限在领取路径上的对外行为:两件 [stack limit] 1 的道具永远拿不到,
// 整封拒绝(ack 4),容器里一行都不留,信保留。
func TestMailboxExtractRefusesAttachmentAboveContainerStackLimit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
if err := repos.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
CharacterID: "77",
Mails: map[string]dnfrepo.MailRecord{
"1": {MailID: "1", Attachments: []dnfrepo.MailAttachment{{ItemID: 2001, Count: 2}}},
},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
Repositories: repos,
MailboxItemResolver: func(uint32) (alignedcmd.MailboxItemResolution, error) {
return mailClaimStackLimitResolution(121, 176, 1), nil
},
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
box, _, _ := repos.Mailbox.Load(ctx, "77")
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("capped claim must retain the attachment: %+v", box.Mails["1"])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if len(inventory.Slots) != 0 {
t.Fatalf("capped claim wrote rows: %+v", inventory.Slots)
}
}
// 没写 [stack limit] 的道具沿用旧行为:容器里那一行不在本次落点范围内时,
// 仍然可以在范围内开新行(有 [stack limit] 的道具此时必须拒绝)。
func TestAddAttachmentKeepsUncappedAndCappedBehaviorApart(t *testing.T) {
uncapped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 5, Extra: map[string]string{"item_kind": "stackable"}},
}}
changed, err := addAttachmentToInventory(&uncapped, dnfrepo.MailAttachment{ItemID: 1001, Count: 3},
mailClaimStackLimitResolution(65, 120, 0))
if err != nil {
t.Fatalf("uncapped addAttachmentToInventory error = %v", err)
}
if len(changed) != 1 || changed[0] != 65 {
t.Fatalf("uncapped changed slots = %v, want a fresh row at 65", changed)
}
if stack := uncapped.Slots["0:65"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("uncapped fresh row = %+v", stack)
}
if stack := uncapped.Slots["0:9"]; stack.Count != 5 {
t.Fatalf("uncapped existing row was mutated: %+v", stack)
}
capped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 5, Extra: map[string]string{"item_kind": "stackable"}},
}}
if _, err := addAttachmentToInventory(&capped, dnfrepo.MailAttachment{ItemID: 1001, Count: 3},
mailClaimStackLimitResolution(65, 120, 1000)); !errors.Is(err, ErrMailboxInventoryFull) {
t.Fatalf("capped error = %v, want ErrMailboxInventoryFull", err)
}
if len(capped.Slots) != 1 {
t.Fatalf("capped claim opened a second row: %+v", capped.Slots)
}
}
// 领取路径的对外行为:[stack limit] 1 且容器里已有一件时整封拒绝(ack 4),
// 信件和附件都保留,容器不变。
func TestMailboxExtractRetainsLetterWhenStackLimitOneContainerIsFull(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", Name: "receiver"})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 2001, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
}}); err != nil {
t.Fatalf("save inventory: %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: func(uint32) (alignedcmd.MailboxItemResolution, error) {
return mailClaimStackLimitResolution(121, 176, 1), nil
},
Body: currentMailIDList(1),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
box, _, _ := repos.Mailbox.Load(ctx, "77")
if box.Mails["1"].Claimed || len(box.Mails["1"].Attachments) != 1 {
t.Fatalf("stack-limit rejection must retain the attachment: %+v", box.Mails["1"])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
if len(inventory.Slots) != 1 || inventory.Slots["0:121"].Count != 1 {
t.Fatalf("rejected claim mutated the container: %+v", inventory.Slots)
}
}