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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user