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,280 @@
package mail
import (
"bytes"
"context"
"encoding/binary"
"testing"
"time"
"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 TestMailboxAttachmentClaimObjectRoundTrip(t *testing.T) {
for _, mailID := range []uint32{1, 7, 52, 65535} {
for index := 0; index < mailboxMaxAttachmentsPerMail; index++ {
claimObject, err := MailboxAttachmentClaimObject(mailID, index)
if err != nil {
t.Fatalf("mail %d index %d: %v", mailID, index, err)
}
if claimObject < MailboxAttachmentSlotFlag {
t.Fatalf("claim object %d must carry the slot flag", claimObject)
}
gotMail, gotIndex, single := DecodeMailboxClaimObject(claimObject)
if !single || gotMail != mailID || gotIndex != index {
t.Fatalf("decode(%d) = (%d, %d, %t), want (%d, %d, true)", claimObject, gotMail, gotIndex, single, mailID, index)
}
}
}
if _, err := MailboxAttachmentClaimObject(7, mailboxMaxAttachmentsPerMail); err == nil {
t.Fatal("an out-of-range attachment index must be rejected")
}
// A plain mail id addresses the whole letter, which is what this client sends.
if gotMail, gotIndex, single := DecodeMailboxClaimObject(52); single || gotMail != 52 || gotIndex != 0 {
t.Fatalf("decode(52) = (%d, %d, %t), want the whole letter", gotMail, gotIndex, single)
}
}
func mailboxSlotTestRepos(t *testing.T, ctx context.Context, attachments []dnfrepo.MailAttachment) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
mustSaveCharacter(t, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc-a", Name: "receiver"})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}}); 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: attachments}},
}); err != nil {
t.Fatalf("save mailbox: %v", err)
}
return repos
}
func mailboxSlotClaim(repos dnfrepo.Group, claimObject uint32) alignedcmd.Request {
return alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMailboxExtractItem),
SelectedCharacterID: 77,
AccountID: "acc-a",
Repositories: repos,
MailboxItemResolver: mailboxClaimTestResolver(65, 120, 0),
Body: currentMailIDList(claimObject),
}
}
// TestMailboxSnapshotKeepsOneRowPerAttachment pins what the page carries: one
// summary row per attachment, each with its own claim object and all of them
// keyed to the same letter. sub_2709990 merges the rows by that letter id but
// aborts the merge whenever the incoming claim object already sits in the
// entry's vector, so equal claim objects collapse a letter to one visible item.
func TestMailboxSnapshotKeepsOneRowPerAttachment(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
mail := dnfrepo.MailRecord{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
Body: "背包空间不足",
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{
{ItemID: 42, Count: 7},
{ItemID: 44, Count: 1},
{ItemID: 10000391, Count: 2},
},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatalf("build snapshot: %v", err)
}
if body[0] != 3 {
t.Fatalf("summary count = %d, want one row per attachment", body[0])
}
want, err := MailboxAttachmentClaimObject(52, 0)
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(body[2:6]); got != want {
t.Fatalf("leading dword = %d, want the first slot's claim object %d", got, want)
}
// Every slot must reach the client with its own claim object, or the merge
// drops it and the letter renders short.
for index := range mail.Attachments {
claimObject, err := MailboxAttachmentClaimObject(52, index)
if err != nil {
t.Fatal(err)
}
var encoded [4]byte
binary.LittleEndian.PutUint32(encoded[:], claimObject)
if !bytes.Contains(body, encoded[:]) {
t.Fatalf("slot %d claim object %d is missing from the page", index, claimObject)
}
}
}
// TestMailboxExtractClaimsOneSlot covers the slot-addressed claim the decoder
// supports: it delivers exactly that attachment, keeps the other slots at their
// original positions and leaves the letter claimable.
func TestMailboxExtractClaimsOneSlot(t *testing.T) {
ctx := context.Background()
repos := mailboxSlotTestRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: 2002, Count: 3},
{ItemID: 2003, Count: 1},
})
claimObject, err := MailboxAttachmentClaimObject(1, 1)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, mailboxSlotClaim(repos, claimObject))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) == 2 && body[0] == 0x00 {
t.Fatalf("slot claim was rejected with code %d", body[1])
}
// (count, letter id, claim object, mode) - the letter id keys the client's
// container and the claim object selects the slot inside it.
if binary.LittleEndian.Uint32(body[1:5]) != 1 ||
binary.LittleEndian.Uint32(body[5:9]) != 1 ||
binary.LittleEndian.Uint32(body[9:13]) != claimObject ||
binary.LittleEndian.Uint32(body[13:17]) != 0 {
t.Fatalf("ack must report the letter and the claimed object: % X", body)
}
// A letter with slots still pending is not retired from the client's list.
if len(got.PostItemSlotRefreshResponses) != 1 ||
!bytes.Equal(got.PostItemSlotRefreshResponses[0].Body, buildMailboxRemoveNotification(nil)) {
t.Fatalf("partial claim must not remove the letter: %+v", got.PostItemSlotRefreshResponses)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
for _, stack := range inventory.Slots {
if stack.ItemID == 2001 || stack.ItemID == 2003 {
t.Fatalf("only the addressed slot may be delivered: %+v", stack)
}
}
delivered := int64(0)
for _, stack := range inventory.Slots {
if stack.ItemID == 2002 {
delivered += stack.Count
}
}
if delivered != 3 {
t.Fatalf("claimed slot delivered %d, want 3", delivered)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed {
t.Fatalf("a letter with pending slots must stay claimable: %+v", mail)
}
if len(mail.Attachments) != 3 || mail.Attachments[0].ItemID != 2001 || mail.Attachments[1].ItemID != 0 || mail.Attachments[2].ItemID != 2003 {
t.Fatalf("attachments = %+v, want the claimed slot blanked in place", mail.Attachments)
}
}
// TestMailboxExtractWholeLetterClaimsEveryAttachment is what this client
// actually sends: the letter id, which hands over everything the letter holds
// and reports one row per slot.
func TestMailboxExtractWholeLetterClaimsEveryAttachment(t *testing.T) {
ctx := context.Background()
repos := mailboxSlotTestRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: 2002, Count: 1},
})
got, err := NewHandler().Handle(ctx, mailboxSlotClaim(repos, 1))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
body := got.UpperResponses[0].Body
if len(body) != 5+2*12 || binary.LittleEndian.Uint32(body[1:5]) != 2 {
t.Fatalf("whole-letter ack must report both slots: % X", body)
}
for index := 0; index < 2; index++ {
want, _ := MailboxAttachmentClaimObject(1, index)
offset := 5 + index*12
if binary.LittleEndian.Uint32(body[offset:offset+4]) != 1 || binary.LittleEndian.Uint32(body[offset+4:offset+8]) != want {
t.Fatalf("ack row %d = % X, want letter 1 slot object %d", index, body[offset:offset+12], want)
}
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if mail := box.Mails["1"]; !mail.Claimed || len(mail.Attachments) != 0 {
t.Fatalf("a whole-letter claim must close the letter: %+v", mail)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
delivered := 0
for _, stack := range inventory.Slots {
if stack.ItemID == 2001 || stack.ItemID == 2002 {
delivered += int(stack.Count)
}
}
if delivered != 2 {
t.Fatalf("both attachments must be delivered, got %d", delivered)
}
}
// TestMailboxSnapshotRendersALetterPastTheAddressableSlots keeps the other
// "mailbox will not open" shape closed: a letter carrying more attachments than
// a claim object can address must still produce a page. The slots past the
// sixteenth fall back to the letter id and merge into its first slot instead of
// failing the whole snapshot.
func TestMailboxSnapshotRendersALetterPastTheAddressableSlots(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
attachments := make([]dnfrepo.MailAttachment, 0, mailboxMaxAttachmentsPerMail+4)
for index := 0; index < cap(attachments); index++ {
attachments = append(attachments, dnfrepo.MailAttachment{ItemID: int64(2000 + index), Count: 1})
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
CreatedAt: now,
Attachments: attachments,
}}, 0, now)
if err != nil {
t.Fatalf("an unaddressable slot must never fail the whole page: %v", err)
}
if int(body[0]) != len(attachments) {
t.Fatalf("summary count = %d, want %d", body[0], len(attachments))
}
var letterID [4]byte
binary.LittleEndian.PutUint32(letterID[:], 52)
if !bytes.Contains(body, letterID[:]) {
t.Fatal("the fallback rows must address the letter itself")
}
}
// TestMailboxSnapshotSkipsEmptiedSlots is the "mailbox will not open"
// regression: a letter carrying an already-emptied slot must still render.
func TestMailboxSnapshotSkipsEmptiedSlots(t *testing.T) {
now := time.Date(2026, 8, 25, 6, 0, 0, 0, time.UTC)
mail := dnfrepo.MailRecord{
MailID: "52",
SenderCharacterID: "11",
SenderName: "系统",
CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{
{},
{ItemID: 44, Count: 1},
{ItemID: 0, Count: 5},
},
}
body, err := buildMailboxListNotification([]dnfrepo.MailRecord{mail}, 0, now)
if err != nil {
t.Fatalf("an emptied slot must never fail the whole page: %v", err)
}
if body[0] != 1 {
t.Fatalf("summary count = %d, want only the one remaining attachment", body[0])
}
// The surviving row keeps the claim object of its own position, so a slot the
// client already holds never changes address while its neighbours are emptied.
want, err := MailboxAttachmentClaimObject(52, 1)
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint32(body[2:6]); got != want {
t.Fatalf("leading dword = %d, want slot 1's claim object %d", got, want)
}
}