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

241 lines
9.1 KiB
Go

package mail
import (
"context"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
const (
claimContractItemID int64 = 10000391
claimContractType int64 = 3
claimContractSeconds int64 = 86400
)
func claimContractResolver() alignedcmd.PremiumContractResolver {
return func(itemID int64) (alignedcmd.PremiumContractResolution, error) {
if itemID != claimContractItemID {
return alignedcmd.PremiumContractResolution{}, nil
}
return alignedcmd.PremiumContractResolution{
ItemID: itemID,
PremiumType: claimContractType,
DurationSeconds: claimContractSeconds,
}, nil
}
}
// claimContractRepos builds a character whose consumable tab is completely full,
// which is the state every one of these letters is claimed in.
func claimContractRepos(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.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-a"}); err != nil {
t.Fatalf("save account: %v", err)
}
slots := map[string]dnfrepo.ItemStack{
dnfrevivecoin.WalletKey(): {
ItemID: dnfrevivecoin.WalletItemID,
Count: 4,
Extra: map[string]string{"item_kind": "stackable", "revive_coin_wallet": "1"},
},
}
for slot := int16(3); slot <= 8; slot++ {
slots[fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 800000 + int64(slot), Count: 1}
}
for slot := int16(65); slot <= 120; slot++ {
slots[fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)] = dnfrepo.ItemStack{ItemID: 900000 + int64(slot), Count: 1}
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: slots}); 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 claimContractRequest(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),
PremiumContractResolver: claimContractResolver(),
Body: currentMailIDList(claimObject),
}
}
// TestMailboxClaimActivatesContractWithoutASlot proves a mailed 契约 is delivered
// by activating it: the account premium term grows, no backpack slot is used and
// the claim succeeds against a completely full consumable tab.
func TestMailboxClaimActivatesContractWithoutASlot(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: claimContractItemID, Count: 2},
})
claimObject, err := MailboxAttachmentClaimObject(1, 0)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(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("contract claim was rejected with code %d; a contract needs no slot", body[1])
}
account, found, err := repos.Account.Load(ctx, "acc-a")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
if expireAt := premium.ExpireAt(account, claimContractType); expireAt < time.Now().Unix()+claimContractSeconds {
t.Fatalf("premium expiry = %d, want both contract days", expireAt)
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
for key, stack := range inventory.Slots {
if stack.ItemID == claimContractItemID {
t.Fatalf("the contract must not occupy a slot: %s %+v", key, stack)
}
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
if mail := box.Mails["1"]; !mail.Claimed {
t.Fatalf("the letter's only slot was claimed, so it must close: %+v", mail)
}
// Granting the term is only half the delivery: without the activation
// notification the client keeps drawing the old remaining time, which reads in
// game as a contract that added no days at all.
notice, found := mailboxPremiumActivation(got)
if !found {
t.Fatalf("a claimed contract must send the activation notification: %+v", got.UpperResponses)
}
if notice.Body[2] != byte(claimContractType) {
t.Fatalf("notification premium type = %d, want %d", notice.Body[2], claimContractType)
}
remaining := int64(0)
for shift := 0; shift < 8; shift++ {
remaining |= int64(notice.Body[3+shift]) << (8 * shift)
}
if remaining < 2*claimContractSeconds {
t.Fatalf("notified remaining = %d, want both contract days", remaining)
}
}
// mailboxPremiumActivation finds the class0 premium activation notification in a
// claim response.
func mailboxPremiumActivation(got alignedcmd.Result) (alignedcmd.UpperResponse, bool) {
for _, response := range got.UpperResponses {
if response.MsgID == premium.ActivatedNotificationMessageID && len(response.Body) == 11 {
return response, true
}
}
return alignedcmd.UpperResponse{}, false
}
// TestMailboxClaimCreditsReviveCoinWallet proves a mailed 复活币 goes to the fixed
// slot-1 wallet instead of demanding a consumable slot.
func TestMailboxClaimCreditsReviveCoinWallet(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: dnfrevivecoin.ConsumableItemID, Count: 7},
})
claimObject, err := MailboxAttachmentClaimObject(1, 0)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(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("revive-coin claim was rejected with code %d; the wallet has room", body[1])
}
inventory, _, _ := repos.Inventory.Load(ctx, "77")
wallet := inventory.Slots[dnfrevivecoin.WalletKey()]
if wallet.ItemID != dnfrevivecoin.WalletItemID || wallet.Count != 11 {
t.Fatalf("wallet = %+v, want item %d count 11", wallet, dnfrevivecoin.WalletItemID)
}
for key, stack := range inventory.Slots {
if stack.ItemID == dnfrevivecoin.ConsumableItemID {
t.Fatalf("the coin consumable must not be stored: %s %+v", key, stack)
}
}
}
// TestMailboxClaimKeepsContractWhenAnotherSlotBlocksTheLetter proves the staging
// is per letter: a whole-letter claim that cannot place its ordinary item must
// not grant the contract either.
func TestMailboxClaimKeepsContractWhenAnotherSlotBlocksTheLetter(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: claimContractItemID, Count: 1},
{ItemID: 2001, Count: 1},
})
got, err := NewHandler().Handle(ctx, claimContractRequest(repos, 1))
if err != nil {
t.Fatalf("Handle error = %v", err)
}
assertOneMailboxResponse(t, got, dnfenum.CmdPacketMailboxExtractItem, []byte{0x00, 0x04})
account, _, _ := repos.Account.Load(ctx, "acc-a")
if expireAt := premium.ExpireAt(account, claimContractType); expireAt != 0 {
t.Fatalf("premium expiry = %d, want none for a refused letter", expireAt)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed || len(mail.Attachments) != 2 {
t.Fatalf("a refused letter must keep both attachments: %+v", mail)
}
}
// TestMailboxClaimContractSlotLeavesTheBlockedSlotBehind is the practical case:
// the contract slot is claimed on its own while the item that has nowhere to go
// stays in the letter at its original position.
func TestMailboxClaimContractSlotLeavesTheBlockedSlotBehind(t *testing.T) {
ctx := context.Background()
repos := claimContractRepos(t, ctx, []dnfrepo.MailAttachment{
{ItemID: 2001, Count: 1},
{ItemID: claimContractItemID, Count: 1},
})
claimObject, err := MailboxAttachmentClaimObject(1, 1)
if err != nil {
t.Fatal(err)
}
got, err := NewHandler().Handle(ctx, claimContractRequest(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("the contract slot was rejected with code %d", body[1])
}
account, _, _ := repos.Account.Load(ctx, "acc-a")
if expireAt := premium.ExpireAt(account, claimContractType); expireAt < time.Now().Unix() {
t.Fatalf("premium expiry = %d, want the contract activated", expireAt)
}
box, _, _ := repos.Mailbox.Load(ctx, "77")
mail := box.Mails["1"]
if mail.Claimed {
t.Fatalf("the blocked slot keeps the letter claimable: %+v", mail)
}
if len(mail.Attachments) != 2 || mail.Attachments[0].ItemID != 2001 || mail.Attachments[1].ItemID != 0 {
t.Fatalf("attachments = %+v, want the contract slot blanked in place", mail.Attachments)
}
}