服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
974 lines
43 KiB
Go
974 lines
43 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfdungeonpermission "longheng.io/server/internal/modules/dnf/dungeonpermission"
|
|
"longheng.io/server/internal/modules/dnf/mail"
|
|
"longheng.io/server/internal/modules/dnf/premium"
|
|
dnfprogression "longheng.io/server/internal/modules/dnf/progression"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
func TestGMSystemMailUsesRepositoryPVFAndRefreshesOnlineMailbox(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{"account_cera": "456"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88",
|
|
AccountID: "dnf:test",
|
|
Name: "Receiver",
|
|
Level: 90,
|
|
Stats: map[string]int64{"gold": 123},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := newPVFDungeonDropCatalog(dungeonDropCatalogTestSource{
|
|
"monster/monster.lst": "",
|
|
"stackable/stackable.lst": "3227 `material/test.stk`\n",
|
|
"equipment/equipment.lst": "5001 `weapon/test.equ`\n",
|
|
"stackable/material/test.stk": "[name] `Test Material`\n[stackable type] `[material]`\n[stack limit] 999\n",
|
|
"equipment/weapon/test.equ": "[name] `Test Weapon`\n[equipment type] `[weapon]`\n[minimum level] 90\n[rarity] 4\n[durability] 45\n",
|
|
currentAmplifyItemPVFPath: testAmplifyItemConfigText(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
recipient := &gameSession{
|
|
conn: connection,
|
|
connID: "gm-mail-online-recipient",
|
|
channel: channelcatalog.Channel{ID: 19},
|
|
selectedCharacterID: 88,
|
|
}
|
|
service := &Service{
|
|
options: options{
|
|
accountID: "dnf:test",
|
|
gameUpperHeader: gameUpperHeaderServer16,
|
|
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
|
},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: recipient},
|
|
pvfItemCatalog: catalog,
|
|
}
|
|
|
|
characters, err := service.GMCharacters(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GMCharacters() error = %v", err)
|
|
}
|
|
if len(characters) != 1 || characters[0].CharacterID != "88" || characters[0].Gold != 123 ||
|
|
characters[0].Cera != 456 || !characters[0].Online {
|
|
t.Fatalf("GMCharacters() = %+v", characters)
|
|
}
|
|
result, err := service.GMSendSystemMail(ctx, GMSystemMailRequest{
|
|
CharacterID: "88",
|
|
Title: "GM reward",
|
|
Body: "Native service mail",
|
|
Gold: 1000,
|
|
Attachments: []GMSystemMailAttachment{
|
|
{ItemID: 3227, Count: 5, Bind: true},
|
|
{ItemID: 5001, Count: 1, AmplifyLevel: 31, AmplifyType: 1},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GMSendSystemMail() error = %v", err)
|
|
}
|
|
if result.MailID != "1" || result.RecipientCharacterID != "88" || !result.Online || !result.Refreshed || result.RefreshError != "" {
|
|
t.Fatalf("GMSendSystemMail() = %+v", result)
|
|
}
|
|
mailbox, found, err := repositories.Mailbox.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load mailbox found=%t error=%v", found, err)
|
|
}
|
|
got := mailbox.Mails["1"]
|
|
if got.Gold != 1000 || len(got.Attachments) != 2 || got.Attachments[0].ItemID != 3227 ||
|
|
got.Attachments[0].Count != 5 || !got.Attachments[0].Bind || len(got.Attachments[0].RawEntry) != currentItemListEntryWireSize ||
|
|
got.Attachments[0].Extra["source"] != "gm_admin_system_mail" {
|
|
t.Fatalf("durable GM mail = %+v", got)
|
|
}
|
|
equipment := got.Attachments[1]
|
|
if equipment.ItemID != 5001 || equipment.Count != 1 || len(equipment.RawEntry) != currentItemListEntryWireSize || equipment.RawEntry[0x0a]&0x1f != 31 ||
|
|
equipment.RawEntry[0x13] != 1 || binary.LittleEndian.Uint16(equipment.RawEntry[0x14:0x16]) != 7 {
|
|
t.Fatalf("durable upgraded equipment attachment = %+v", equipment)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(packets) != 1 || packets[0].Header.MsgID != 0x0063 || packets[0].Header.Classification != 0 {
|
|
t.Fatalf("live refresh packets = %+v", packets)
|
|
}
|
|
}
|
|
|
|
func TestGMSystemMailRareAvatarUsesNativeListOneRawRow(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "dnf:test"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := newPVFDungeonDropCatalog(dungeonDropCatalogTestSource{
|
|
"monster/monster.lst": "",
|
|
"stackable/stackable.lst": "",
|
|
"equipment/equipment.lst": "6101 `avatar/rare_coat.equ`\n",
|
|
"equipment/avatar/rare_coat.equ": "[name] `Rare Avatar Coat`\n[equipment type] `[coat avatar]`\n[rarity] 4\n[minimum level] 1\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
service := &Service{
|
|
options: options{
|
|
accountID: "dnf:test",
|
|
gameUpperHeader: gameUpperHeaderServer16,
|
|
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
|
},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
pvfItemCatalog: catalog,
|
|
gameSessions: map[uint16]*gameSession{88: {
|
|
conn: connection, connID: "gm-rare-avatar-mail", selectedCharacterID: 88,
|
|
}},
|
|
}
|
|
result, err := service.GMSendSystemMail(ctx, GMSystemMailRequest{
|
|
CharacterID: "88",
|
|
Title: "Rare avatar",
|
|
Attachments: []GMSystemMailAttachment{{ItemID: 6101, Count: 1}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.MailID != "1" || !result.Online || !result.Refreshed || result.RefreshError != "" {
|
|
t.Fatalf("GM mail result=%+v", result)
|
|
}
|
|
mailbox, found, err := repositories.Mailbox.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load mailbox found=%t err=%v", found, err)
|
|
}
|
|
attachment := mailbox.Mails["1"].Attachments[0]
|
|
if len(attachment.RawEntry) != currentItemListEntryWireSize ||
|
|
binary.LittleEndian.Uint16(attachment.RawEntry[0:2]) != 0 ||
|
|
binary.LittleEndian.Uint32(attachment.RawEntry[2:6]) != 6101 ||
|
|
binary.LittleEndian.Uint32(attachment.RawEntry[6:10]) != 1 {
|
|
t.Fatalf("rare avatar mail raw=%x extra=%+v", attachment.RawEntry, attachment.Extra)
|
|
}
|
|
definition, err := catalog.ResolveItem(6101)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolution, err := mailboxItemResolutionFromPVF(definition, false)
|
|
if err != nil || resolution.ListType != currentAvatarInventoryListType {
|
|
t.Fatalf("rare avatar resolution=%+v err=%v", resolution, err)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(packets) != 1 || packets[0].Header.MsgID != mail.MailboxAlarmNotificationMessageID ||
|
|
packets[0].Header.Classification != 0 {
|
|
t.Fatalf("rare avatar arrival packets=%+v; unsolicited op97 is unsafe", packets)
|
|
}
|
|
opened, err := mail.NewOwner(repositories).Open(ctx, "88")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err := mail.BuildListNotification(opened)
|
|
if err != nil || len(snapshot) == 0 {
|
|
t.Fatalf("client-owned rare avatar mailbox snapshot len=%d err=%v", len(snapshot), err)
|
|
}
|
|
}
|
|
|
|
func TestGMClearMailboxRequiresConfirmationAndRefreshesOnlineClient(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88",
|
|
AccountID: "dnf:test",
|
|
Name: "Receiver",
|
|
Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := repositories.Mailbox.Save(ctx, dnfrepo.MailboxRecord{
|
|
CharacterID: "88",
|
|
Mails: map[string]dnfrepo.MailRecord{
|
|
"1": {
|
|
MailID: "1",
|
|
Gold: 300,
|
|
Attachments: []dnfrepo.MailAttachment{{ItemID: 3227, Count: 2}},
|
|
CreatedAt: now,
|
|
},
|
|
"2": {MailID: "2", Read: true, Claimed: true, CreatedAt: now},
|
|
},
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
recipient := &gameSession{
|
|
conn: connection,
|
|
connID: "gm-mail-clear-online-recipient",
|
|
channel: channelcatalog.Channel{ID: 19},
|
|
selectedCharacterID: 88,
|
|
}
|
|
service := &Service{
|
|
options: options{
|
|
accountID: "dnf:test",
|
|
gameUpperHeader: gameUpperHeaderServer16,
|
|
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
|
},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: recipient},
|
|
}
|
|
|
|
if _, err := service.GMClearMailbox(ctx, GMMailboxClearRequest{CharacterName: "Receiver"}); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("GMClearMailbox() without confirmation error = %v", err)
|
|
}
|
|
before, found, err := repositories.Mailbox.Load(ctx, "88")
|
|
if err != nil || !found || len(before.Mails) != 2 {
|
|
t.Fatalf("mailbox before confirmed clear found=%t error=%v record=%+v", found, err, before)
|
|
}
|
|
|
|
result, err := service.GMClearMailbox(ctx, GMMailboxClearRequest{CharacterName: "Receiver", Confirm: true})
|
|
if err != nil {
|
|
t.Fatalf("GMClearMailbox() error = %v", err)
|
|
}
|
|
if result.CharacterID != "88" || result.CharacterName != "Receiver" || result.RemovedMails != 2 ||
|
|
result.RemovedUnread != 1 || result.RemovedClaimable != 1 || result.RemovedGold != 300 ||
|
|
result.RemovedAttachments != 1 || !result.Online || !result.Refreshed || result.RefreshError != "" {
|
|
t.Fatalf("GMClearMailbox() = %+v", result)
|
|
}
|
|
after, found, err := repositories.Mailbox.Load(ctx, "88")
|
|
if err != nil || !found || len(after.Mails) != 0 {
|
|
t.Fatalf("mailbox after clear found=%t error=%v record=%+v", found, err, after)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(packets) != 2 || packets[0].Header.MsgID != 0x0063 || packets[0].Header.Classification != 0 ||
|
|
packets[1].Header.MsgID != 0x0061 || packets[1].Header.Classification != 0 {
|
|
t.Fatalf("mailbox clear refresh packets = %+v", packets)
|
|
}
|
|
}
|
|
|
|
func TestGMSystemMailOfflineRecipientCommitsWithoutTransport(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "9",
|
|
AccountID: "dnf:test",
|
|
Name: "Offline",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: make(map[uint16]*gameSession),
|
|
}
|
|
result, err := service.GMSendSystemMail(ctx, GMSystemMailRequest{
|
|
CharacterName: "Offline",
|
|
Body: "Text-only system mail",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GMSendSystemMail() error = %v", err)
|
|
}
|
|
if result.MailID != "1" || result.Online || result.Refreshed {
|
|
t.Fatalf("offline result = %+v", result)
|
|
}
|
|
mailbox, found, err := repositories.Mailbox.Load(ctx, "9")
|
|
if err != nil || !found || mailbox.Mails["1"].Body != "Text-only system mail" {
|
|
t.Fatalf("offline mailbox found=%t error=%v record=%+v", found, err, mailbox)
|
|
}
|
|
}
|
|
|
|
func TestGMFindCharactersQueriesAccountOrExactCharacterName(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
for _, account := range []dnfrepo.AccountRecord{
|
|
{AccountID: "dnf:one", Metadata: map[string]string{"account_cera": "11", "login_username": "account-one"}},
|
|
{AccountID: "dnf:two", Metadata: map[string]string{"account_cera": "22", "login_username": "account-two"}},
|
|
} {
|
|
if err := repositories.Account.Save(ctx, account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, character := range []dnfrepo.CharacterRecord{
|
|
{CharacterID: "1", AccountID: "dnf:one", Name: "Alpha", Level: 10, Stats: map[string]int64{"gold": 100}},
|
|
{CharacterID: "2", AccountID: "dnf:two", Name: "Beta", Level: 20, Stats: map[string]int64{"gold": 200}},
|
|
} {
|
|
if err := repositories.Character.Save(ctx, character); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:one"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: make(map[uint16]*gameSession),
|
|
}
|
|
byAccount, err := service.GMFindCharacters(ctx, "dnf:two", "")
|
|
if err != nil || len(byAccount) != 1 || byAccount[0].Name != "Beta" || byAccount[0].Cera != 22 {
|
|
t.Fatalf("GMFindCharacters(account) = %+v error=%v", byAccount, err)
|
|
}
|
|
byName, err := service.GMFindCharacters(ctx, "", "Alpha")
|
|
if err != nil || len(byName) != 1 || byName[0].AccountID != "dnf:one" || byName[0].Gold != 100 {
|
|
t.Fatalf("GMFindCharacters(name) = %+v error=%v", byName, err)
|
|
}
|
|
missing, err := service.GMFindCharacters(ctx, "", "Missing")
|
|
if err != nil || len(missing) != 0 {
|
|
t.Fatalf("GMFindCharacters(missing) = %+v error=%v", missing, err)
|
|
}
|
|
if _, err := service.GMFindCharacters(ctx, "dnf:one", "Alpha"); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("GMFindCharacters accepted two selectors: %v", err)
|
|
}
|
|
byUsername, err := service.GMFindCharactersByUsername(ctx, "ACCOUNT-TWO")
|
|
if err != nil || len(byUsername) != 1 || byUsername[0].Name != "Beta" {
|
|
t.Fatalf("GMFindCharactersByUsername() = %+v error=%v", byUsername, err)
|
|
}
|
|
wallet, err := service.GMReadWallet(ctx, GMWalletRequest{CharacterName: "Beta"})
|
|
if err != nil || wallet.Character.AccountID != "dnf:two" || wallet.Cera != 22 || wallet.Gold != 200 {
|
|
t.Fatalf("GMReadWallet(cross-account) = %+v error=%v", wallet, err)
|
|
}
|
|
updated, err := service.GMUpdateWallet(ctx, GMWalletRequest{
|
|
CharacterID: "2", Currency: "cera", Mode: "add", Amount: 5,
|
|
})
|
|
if err != nil || updated.Before != 22 || updated.After != 27 || updated.Character.AccountID != "dnf:two" {
|
|
t.Fatalf("GMUpdateWallet(cross-account) = %+v error=%v", updated, err)
|
|
}
|
|
}
|
|
|
|
func TestGMUpdateCharacterSkillCanPersistentlyExtendTotalsAcrossTrees(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Job: "2", Level: 30,
|
|
Stats: map[string]int64{"skill_tree_index": 0, "bonus_sp": 5, "bonus_tp": 2},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{
|
|
CharacterID: "88",
|
|
Skills: map[int64]dnfrepo.SkillState{1: {Level: 1, Enabled: true}},
|
|
Points: dnfrepo.SkillPointState{TotalSP: 600, RemainingSP: 100, TotalTP: 10, RemainingTP: 2, SyncedLevel: 30},
|
|
Trees: map[int]dnfrepo.SkillTreeState{1: {
|
|
Skills: map[int64]dnfrepo.SkillState{1: {Level: 1, Enabled: true}},
|
|
Points: dnfrepo.SkillPointState{TotalSP: 600, RemainingSP: 200, TotalTP: 10, RemainingTP: 3, SyncedLevel: 30},
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
|
|
sp, tp := 1000, 50
|
|
if _, err := service.GMUpdateCharacterSkill(ctx, GMCharacterSkillUpdate{
|
|
CharacterID: "88", RemainingSP: &sp, RemainingTP: &tp,
|
|
}); !errors.Is(err, ErrGMRequestInvalid) || !strings.Contains(err.Error(), "extend_total") {
|
|
t.Fatalf("GMUpdateCharacterSkill() without extension error=%v", err)
|
|
}
|
|
before, _, _ := repositories.Skill.Load(ctx, "88")
|
|
if before.Points.TotalSP != 600 || before.Points.RemainingSP != 100 || before.Trees[1].Points.TotalSP != 600 {
|
|
t.Fatalf("rejected GM extension mutated skill=%+v", before)
|
|
}
|
|
|
|
result, err := service.GMUpdateCharacterSkill(ctx, GMCharacterSkillUpdate{
|
|
CharacterID: "88", RemainingSP: &sp, RemainingTP: &tp, ExtendTotal: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ActiveTree != 0 || result.TotalSP != 1000 || result.RemainingSP != 1000 || result.AddedSP != 400 ||
|
|
result.TotalTP != 50 || result.RemainingTP != 50 || result.AddedTP != 40 || result.Online || result.Refreshed {
|
|
t.Fatalf("GMUpdateCharacterSkill() result=%+v", result)
|
|
}
|
|
character, _, _ := repositories.Character.Load(ctx, "88")
|
|
skill, _, _ := repositories.Skill.Load(ctx, "88")
|
|
if character.Stats["bonus_sp"] != 405 || character.Stats["bonus_tp"] != 42 ||
|
|
skill.Points.TotalSP != 1000 || skill.Points.RemainingSP != 1000 || skill.Points.TotalTP != 50 || skill.Points.RemainingTP != 50 ||
|
|
skill.Trees[1].Points.TotalSP != 1000 || skill.Trees[1].Points.RemainingSP != 600 ||
|
|
skill.Trees[1].Points.TotalTP != 50 || skill.Trees[1].Points.RemainingTP != 43 {
|
|
t.Fatalf("persistent GM SP/TP extension character=%+v skill=%+v", character, skill)
|
|
}
|
|
info, err := service.GMReadCharacterSkill(ctx, "88")
|
|
if err != nil || info.TotalSP != 1000 || info.RemainingSP != 1000 || info.TotalTP != 50 || info.RemainingTP != 50 {
|
|
t.Fatalf("GMReadCharacterSkill()=%+v error=%v", info, err)
|
|
}
|
|
|
|
overflow := 65536
|
|
if _, err := service.GMUpdateCharacterSkill(ctx, GMCharacterSkillUpdate{
|
|
CharacterID: "88", RemainingSP: &overflow, ExtendTotal: true,
|
|
}); !errors.Is(err, ErrGMRequestInvalid) || !strings.Contains(err.Error(), "0..65535") {
|
|
t.Fatalf("GMUpdateCharacterSkill() overflow error=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestGMNativeStateOperationsUseRepositoryTransactions(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{"account_cera": "100"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Job: "2", Level: 30,
|
|
Stats: map[string]int64{"gold": 200, "grow_type": 0},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{
|
|
CharacterID: "88",
|
|
Skills: map[int64]dnfrepo.SkillState{1: {Level: 1, Enabled: true}},
|
|
Points: dnfrepo.SkillPointState{TotalSP: 600, RemainingSP: 100, SyncedLevel: 30},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
profiles, skillCatalog := finishBridgeProfessionResources(t)
|
|
spTable := make(map[int]int, 90)
|
|
tpTable := make(map[int]int, 90)
|
|
for level := 1; level <= 90; level++ {
|
|
spTable[level] = 20
|
|
if level >= 50 {
|
|
tpTable[level] = 1
|
|
}
|
|
}
|
|
connection := &bufferConn{}
|
|
experienceSource := bridgePVFSource{
|
|
dnfprogression.ExperienceTablePath: strings.TrimSpace(strings.Repeat("100 ", 89)),
|
|
}
|
|
experienceTable, err := dnfprogression.LoadExperienceTable(ctx, experienceSource)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
initialSkillsByJob: map[byte][]initialSkillEntry{2: {{SkillID: 1, Level: 1}}},
|
|
initialSPTable: spTable,
|
|
initialTPTable: tpTable,
|
|
experienceTable: experienceTable,
|
|
skillCatalog: skillCatalog,
|
|
professionProfiles: profiles,
|
|
questCatalog: finishBridgeProfessionCatalog(t),
|
|
gameSessions: map[uint16]*gameSession{88: {
|
|
conn: connection, connID: "gm-state-online", selectedCharacterID: 88,
|
|
}},
|
|
}
|
|
|
|
cera, err := service.GMUpdateWallet(ctx, GMWalletRequest{CharacterID: "88", Currency: "cera", Mode: "add", Amount: 50})
|
|
if err != nil || cera.Before != 100 || cera.After != 150 || cera.Cera != 150 || !cera.Refreshed {
|
|
t.Fatalf("GMUpdateWallet(cera) = %+v error=%v", cera, err)
|
|
}
|
|
gold, err := service.GMUpdateWallet(ctx, GMWalletRequest{CharacterName: "Receiver", Currency: "gold", Mode: "set", Amount: 999})
|
|
if err != nil || gold.Before != 200 || gold.After != 999 || gold.Gold != 999 || !gold.Refreshed {
|
|
t.Fatalf("GMUpdateWallet(gold) = %+v error=%v", gold, err)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(packets) != 2 || packets[0].Header.MsgID != currentCeraShopUpdateMsgID ||
|
|
packets[1].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
|
|
t.Fatalf("wallet refresh packets = %+v", packets)
|
|
}
|
|
|
|
sp, tp := 500, 0
|
|
connection.write.Reset()
|
|
skillUpdate, err := service.GMUpdateCharacterSkill(ctx, GMCharacterSkillUpdate{CharacterID: "88", RemainingSP: &sp, RemainingTP: &tp})
|
|
if err != nil || !skillUpdate.Refreshed {
|
|
t.Fatal(err)
|
|
}
|
|
skillPackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(skillPackets) != 1 || skillPackets[0].Header.Classification != 0 || skillPackets[0].Header.MsgID != currentSkillInfoMsgID {
|
|
t.Fatalf("GM SP/TP native packets = %+v", skillPackets)
|
|
}
|
|
skillInfo, err := service.GMReadCharacterSkill(ctx, "88")
|
|
if err != nil || skillInfo.RemainingSP != sp || skillInfo.RemainingTP != tp {
|
|
t.Fatalf("GMReadCharacterSkill() = %+v error=%v", skillInfo, err)
|
|
}
|
|
connection.write.Reset()
|
|
levelUpdate, err := service.GMSetCharacterLevel(ctx, "88", 90)
|
|
if err != nil || !levelUpdate.Refreshed || levelUpdate.PreviousLevel != 30 || levelUpdate.Level != 90 {
|
|
t.Fatalf("GMSetCharacterLevel() = %+v error=%v", levelUpdate, err)
|
|
}
|
|
levelPackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
levelWant := []uint16{currentDungeonCharacterStateMsgID, currentSkillInfoMsgID, currentActiveQuestSnapshotMsgID, currentClearQuestListMsgID, currentAcceptableQuestListMsgID}
|
|
if len(levelPackets) != len(levelWant) {
|
|
t.Fatalf("GM level packet count=%d packets=%+v", len(levelPackets), levelPackets)
|
|
}
|
|
for index, want := range levelWant {
|
|
if levelPackets[index].Header.MsgID != want {
|
|
t.Fatalf("GM level packet[%d]=%d want=%d", index, levelPackets[index].Header.MsgID, want)
|
|
}
|
|
}
|
|
connection.write.Reset()
|
|
jobUpdate, err := service.GMSetCharacterJob(ctx, GMCharacterJobUpdate{CharacterID: "88", Job: 2, FirstGrow: 2, SecondGrow: 0})
|
|
if err != nil || !jobUpdate.Refreshed || jobUpdate.TransitionCount != 1 {
|
|
t.Fatalf("GMSetCharacterJob() = %+v error=%v", jobUpdate, err)
|
|
}
|
|
jobPackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
jobWant := []uint16{uint16(dnfenum.CmdPacketSetUDPIPPort), uint16(dnfenum.CmdPacketSetUDPIPPort), currentSkillInfoMsgID, currentActiveQuestSnapshotMsgID, currentClearQuestListMsgID, currentAcceptableQuestListMsgID}
|
|
if len(jobPackets) != len(jobWant) {
|
|
t.Fatalf("GM profession packet count=%d packets=%+v", len(jobPackets), jobPackets)
|
|
}
|
|
for index, want := range jobWant {
|
|
if jobPackets[index].Header.MsgID != want {
|
|
t.Fatalf("GM profession packet[%d]=%d want=%d", index, jobPackets[index].Header.MsgID, want)
|
|
}
|
|
}
|
|
job, err := service.GMReadCharacterJob(ctx, "88")
|
|
if err != nil || job.Job != 2 || job.FirstGrow != 2 || job.SecondGrow != 0 {
|
|
t.Fatalf("GMReadCharacterJob() = %+v error=%v", job, err)
|
|
}
|
|
character, _, _ := repositories.Character.Load(ctx, "88")
|
|
if character.Level != 90 || character.Stats["exp"] != 100 {
|
|
t.Fatalf("level = %d cumulative_exp=%d, want level=90 PVF entry exp=100", character.Level, character.Stats["exp"])
|
|
}
|
|
character.Stats["exp"] = 0
|
|
if err := repositories.Character.Save(ctx, character); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
repaired, err := service.GMSetCharacterLevel(ctx, "88", 90)
|
|
if err != nil || !repaired.Refreshed || repaired.PreviousLevel != 90 {
|
|
t.Fatalf("same-level GM repair = %+v error=%v", repaired, err)
|
|
}
|
|
character, _, _ = repositories.Character.Load(ctx, "88")
|
|
if character.Stats["exp"] != 100 {
|
|
t.Fatalf("same-level GM repair cumulative_exp=%d, want PVF entry exp=100", character.Stats["exp"])
|
|
}
|
|
skill, _, _ := repositories.Skill.Load(ctx, "88")
|
|
if skill.Skills[2].Level != 1 || skill.Skills[197].Level != 1 || skill.Points.RemainingSP != skill.Points.TotalSP || skill.Points.RemainingTP != skill.Points.TotalTP {
|
|
t.Fatalf("PVF profession skill transition = %+v", skill)
|
|
}
|
|
// A same-branch forward awakening remains the ordinary additive path: it
|
|
// preserves learned levels and spent points while adding the PVF awakening
|
|
// grant. This must stay separate from the administrative rebase below.
|
|
skill.Skills[2] = dnfrepo.SkillState{Level: 7, Enabled: true}
|
|
skill.Points.RemainingSP = 1
|
|
if err := repositories.Skill.Save(ctx, skill); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
awakeningUpdate, err := service.GMSetCharacterJob(ctx, GMCharacterJobUpdate{CharacterID: "88", Job: 2, FirstGrow: 2, SecondGrow: 1})
|
|
if err != nil || !awakeningUpdate.Refreshed || awakeningUpdate.PreviousGrowType != 2 || awakeningUpdate.GrowType != 0x12 || awakeningUpdate.TransitionCount != 1 {
|
|
t.Fatalf("GM forward awakening = %+v error=%v", awakeningUpdate, err)
|
|
}
|
|
skill, _, _ = repositories.Skill.Load(ctx, "88")
|
|
if skill.Skills[2].Level != 7 || skill.Skills[4].Level != 1 || skill.Points.RemainingSP != 1 {
|
|
t.Fatalf("GM forward awakening replaced learned state = %+v", skill)
|
|
}
|
|
// A GM branch rebase is intentionally destructive to the old branch: it
|
|
// must not leak purchased skills, quick-slot layouts, commands or cooldowns
|
|
// into the target profession. Seed all of those states before switching.
|
|
skill.Skills[99] = dnfrepo.SkillState{Level: 7, Enabled: true}
|
|
skill.Points.RemainingSP = 1
|
|
skill.Points.RemainingTP = 0
|
|
alternate, found := dnfrepo.SkillTree(skill, 1)
|
|
if !found {
|
|
t.Fatalf("PVF profession alternate tree is unavailable: %+v", skill)
|
|
}
|
|
alternate.Skills[98] = dnfrepo.SkillState{Level: 5, Enabled: true}
|
|
alternate.Points.RemainingSP = 2
|
|
alternate.Points.RemainingTP = 0
|
|
dnfrepo.SetSkillTree(&skill, 1, alternate)
|
|
skill.Layouts[0][11] = 99
|
|
skill.Layouts[1][11] = 98
|
|
skill.Commands = map[int64][]byte{99: {1, 2, 3}}
|
|
skill.Cooldowns = map[int64]time.Time{99: time.Unix(1_800_000_000, 0).UTC()}
|
|
if err := repositories.Skill.Save(ctx, skill); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
branchUpdate, err := service.GMSetCharacterJob(ctx, GMCharacterJobUpdate{CharacterID: "88", Job: 2, FirstGrow: 1, SecondGrow: 0})
|
|
if err != nil || !branchUpdate.Refreshed || branchUpdate.PreviousGrowType != 0x12 || branchUpdate.GrowType != 1 || branchUpdate.TransitionCount != 1 {
|
|
t.Fatalf("GM branch rebase = %+v error=%v", branchUpdate, err)
|
|
}
|
|
branchPackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(branchPackets) != len(jobWant) {
|
|
t.Fatalf("GM branch-rebase packet count=%d packets=%+v", len(branchPackets), branchPackets)
|
|
}
|
|
for index, want := range jobWant {
|
|
if branchPackets[index].Header.MsgID != want {
|
|
t.Fatalf("GM branch-rebase packet[%d]=%d want=%d", index, branchPackets[index].Header.MsgID, want)
|
|
}
|
|
}
|
|
job, err = service.GMReadCharacterJob(ctx, "88")
|
|
if err != nil || job.Job != 2 || job.FirstGrow != 1 || job.SecondGrow != 0 {
|
|
t.Fatalf("GMReadCharacterJob(after rebase) = %+v error=%v", job, err)
|
|
}
|
|
skill, found, err = repositories.Skill.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load GM rebased skill found=%t error=%v", found, err)
|
|
}
|
|
primary, primaryFound := dnfrepo.SkillTree(skill, 0)
|
|
alternate, alternateFound := dnfrepo.SkillTree(skill, 1)
|
|
for _, tree := range []struct {
|
|
name string
|
|
state dnfrepo.SkillTreeState
|
|
found bool
|
|
}{
|
|
{name: "primary", state: primary, found: primaryFound},
|
|
{name: "alternate", state: alternate, found: alternateFound},
|
|
} {
|
|
if !tree.found || len(tree.state.Skills) != 2 || tree.state.Skills[1].Level != 1 || tree.state.Skills[3].Level != 1 ||
|
|
tree.state.Skills[2].Level != 0 || tree.state.Skills[197].Level != 0 || tree.state.Skills[98].Level != 0 || tree.state.Skills[99].Level != 0 ||
|
|
tree.state.Points.RemainingSP != tree.state.Points.TotalSP || tree.state.Points.RemainingTP != tree.state.Points.TotalTP {
|
|
t.Fatalf("GM rebased %s tree=%+v found=%t", tree.name, tree.state, tree.found)
|
|
}
|
|
}
|
|
if len(skill.Commands) != 0 || len(skill.Cooldowns) != 0 || len(skill.Layouts) != 2 {
|
|
t.Fatalf("GM rebased transient state=%+v", skill)
|
|
}
|
|
for tree, layout := range skill.Layouts {
|
|
for _, skillID := range layout {
|
|
if skillID != 1 && skillID != 3 {
|
|
t.Fatalf("GM rebased layout tree=%d contains obsolete skill=%d layout=%+v", tree, skillID, layout)
|
|
}
|
|
}
|
|
}
|
|
// Returning to the base profession must also drop the persisted class
|
|
// quest (100 is [job change quest] 1 in the test catalog) so the chain can
|
|
// be accepted again, while unrelated quest rows survive.
|
|
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
|
|
CharacterID: "88",
|
|
States: map[int64]dnfrepo.QuestState{
|
|
100: {Status: "completed"},
|
|
3145: {Status: "completed"},
|
|
},
|
|
Progress: map[int64]dnfrepo.QuestState{
|
|
100: {Status: "active"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
baseUpdate, err := service.GMSetCharacterJob(ctx, GMCharacterJobUpdate{CharacterID: "88", Job: 2, FirstGrow: 0, SecondGrow: 0})
|
|
if err != nil || !baseUpdate.Refreshed || baseUpdate.PreviousGrowType != 1 || baseUpdate.GrowType != 0 ||
|
|
!reflect.DeepEqual(baseUpdate.QuestResetIDs, []int64{100}) {
|
|
t.Fatalf("GM base-career reset = %+v error=%v", baseUpdate, err)
|
|
}
|
|
basePackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(basePackets) != len(jobWant) {
|
|
t.Fatalf("GM base-career packet count=%d packets=%+v", len(basePackets), basePackets)
|
|
}
|
|
resetQuests, found, err := repositories.Quest.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("load base-career quests found=%t error=%v", found, err)
|
|
}
|
|
if _, exists := resetQuests.States[100]; exists {
|
|
t.Fatalf("profession quest state remained after base-career reset: %+v", resetQuests)
|
|
}
|
|
if _, exists := resetQuests.Progress[100]; exists {
|
|
t.Fatalf("profession quest progress remained after base-career reset: %+v", resetQuests)
|
|
}
|
|
if _, exists := resetQuests.States[3145]; !exists {
|
|
t.Fatalf("ordinary quest was removed by base-career reset: %+v", resetQuests)
|
|
}
|
|
job, err = service.GMReadCharacterJob(ctx, "88")
|
|
if err != nil || job.Job != 2 || job.FirstGrow != 0 || job.SecondGrow != 0 {
|
|
t.Fatalf("GMReadCharacterJob(after base reset) = %+v error=%v", job, err)
|
|
}
|
|
|
|
connection.write.Reset()
|
|
if _, err := service.GMSetCharacterLevel(ctx, "88", 89); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("GM level regression error=%v, want ErrGMRequestInvalid", err)
|
|
}
|
|
if _, err := service.GMSetCharacterJob(ctx, GMCharacterJobUpdate{CharacterID: "88", Job: 5, FirstGrow: 1}); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("GM base-job conversion error=%v, want ErrGMRequestInvalid", err)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("invalid GM progression wrote native packets=%x", connection.write.Bytes())
|
|
}
|
|
|
|
connection.write.Reset()
|
|
equip, err := service.GMUnlockEquipSlots(ctx, "88")
|
|
if err != nil || equip.Before != 0 || equip.After != gmAllEquipSlotsMask || !equip.Refreshed {
|
|
t.Fatalf("GMUnlockEquipSlots() = %+v error=%v", equip, err)
|
|
}
|
|
equipPackets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(equipPackets) != 1 || equipPackets[0].Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) || len(equipPackets[0].Body) < 4 || equipPackets[0].Body[3] != 1 {
|
|
t.Fatalf("GM equipment-slot native packets = %+v", equipPackets)
|
|
}
|
|
dungeons, err := service.GMUnlockDungeons(ctx, GMDungeonUnlockRequest{CharacterID: "88", Dungeons: map[string]int{"100": 4, "200": 0}})
|
|
if err != nil || dungeons.Count != 1 || !dungeons.Online || dungeons.Refreshed || dungeons.RefreshError == "" {
|
|
t.Fatalf("GMUnlockDungeons() = %+v error=%v", dungeons, err)
|
|
}
|
|
accountKey, keyErr := dnfdungeonpermission.AccountOwnerKey(character.AccountID)
|
|
if keyErr != nil {
|
|
t.Fatal(keyErr)
|
|
}
|
|
permission, found, err := repositories.DungeonPermission.Load(ctx, accountKey)
|
|
if err != nil || !found || len(permission.Entries) != 1 || permission.Entries[0].ClearState != 4 {
|
|
t.Fatalf("dungeon permission = %+v found=%t error=%v", permission, found, err)
|
|
}
|
|
}
|
|
|
|
func TestGMActivateBlackDiamondCommitsAccountEntitlementAndDefersUnsafeOnlineRefresh(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{
|
|
"account_cera": "100",
|
|
premium.MetadataKey(29): strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
|
|
Stats: map[string]int64{"fatigue": 120, "fatigue_limit": 156},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection, connID: "gm-black-diamond-online", selectedCharacterID: 88}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: session},
|
|
gameplayTimers: &processGameplayTimeQueue{now: func() time.Time { return now }},
|
|
}
|
|
|
|
result, err := service.GMActivateBlackDiamond(ctx, GMBlackDiamondRequest{CharacterID: "88"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantExpiry := now.Add(30 * 24 * time.Hour).Unix()
|
|
if result.CharacterID != "88" || result.AccountID != "dnf:test" || result.Days != 30 ||
|
|
result.ExpiresAt != wantExpiry || result.RemainingSeconds != int64(30*24*time.Hour/time.Second) ||
|
|
result.Fatigue != 152 || result.FatigueLimit != 188 || !result.Online || result.Refreshed || !result.Deferred || result.RefreshError != "" {
|
|
t.Fatalf("GMActivateBlackDiamond() = %+v", result)
|
|
}
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("deferred Black Diamond activation wrote unsafe town packets=%x", connection.write.Bytes())
|
|
}
|
|
if !service.currentBlackDiamondActive(session) {
|
|
t.Fatal("online session did not receive the committed Black Diamond expiry")
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, "dnf:test")
|
|
if err != nil || !found {
|
|
t.Fatalf("load account found=%t err=%v", found, err)
|
|
}
|
|
if premium.BlackDiamondExpireAt(account) != wantExpiry || account.Metadata[premium.MetadataKey(29)] == "" || account.Metadata["account_cera"] != "100" {
|
|
t.Fatalf("durable Black Diamond account metadata=%+v", account.Metadata)
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, "88")
|
|
if err != nil || !found || character.Stats["fatigue"] != 152 || character.Stats["fatigue_limit"] != 188 {
|
|
t.Fatalf("durable Black Diamond character=%+v found=%t err=%v", character, found, err)
|
|
}
|
|
|
|
delete(service.gameSessions, 88)
|
|
renewed, err := service.GMActivateBlackDiamond(ctx, GMBlackDiamondRequest{CharacterID: "88", Days: 7})
|
|
if err != nil || renewed.ExpiresAt != now.Add(37*24*time.Hour).Unix() || renewed.Online || renewed.Deferred {
|
|
t.Fatalf("renewed Black Diamond result=%+v err=%v", renewed, err)
|
|
}
|
|
if _, err := service.GMActivateBlackDiamond(ctx, GMBlackDiamondRequest{CharacterID: "88", Days: 3651}); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("invalid Black Diamond duration error=%v", err)
|
|
}
|
|
}
|
|
|
|
// TestGMBlackDiamondStatusIsReadOnly:黑钻只读查询必须不落盘、不发包,且读的是
|
|
// 账号级权益(premium_expire_1000)而不是展示类型 56。
|
|
func TestGMBlackDiamondStatusIsReadOnly(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
// 展示类型 56 故意写一个"看起来有效"的值:只读查询必须忽略它。
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{
|
|
premium.MetadataKey(premium.TypeBlackDiamondEntitlement): strconv.FormatInt(now.Add(30*24*time.Hour).Unix(), 10),
|
|
"premium_expire_56": strconv.FormatInt(now.Add(365*24*time.Hour).Unix(), 10),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
|
|
Stats: map[string]int64{"fatigue": 120, "fatigue_limit": 188},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection, connID: "gm-black-diamond-status", selectedCharacterID: 88}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: session},
|
|
gameplayTimers: &processGameplayTimeQueue{now: func() time.Time { return now }},
|
|
}
|
|
|
|
status, err := service.GMBlackDiamondStatus(ctx, GMBlackDiamondQuery{CharacterID: "88"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status.CharacterID != "88" || status.CharacterName != "Receiver" || status.AccountID != "dnf:test" {
|
|
t.Fatalf("GMBlackDiamondStatus() identity = %+v", status)
|
|
}
|
|
if !status.Active || status.ExpiresAt != now.Add(30*24*time.Hour).Unix() ||
|
|
status.RemainingSeconds != int64(30*24*time.Hour/time.Second) || status.RemainingDays != 30 {
|
|
t.Fatalf("GMBlackDiamondStatus() entitlement = %+v", status)
|
|
}
|
|
if status.Fatigue != 120 || status.FatigueLimit != 188 || status.ServerNow != now.Unix() || !status.Online {
|
|
t.Fatalf("GMBlackDiamondStatus() runtime = %+v", status)
|
|
}
|
|
// 只读:不能写任何网络包,也不能改动账号/角色数据。
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("read-only query wrote packets=%x", connection.write.Bytes())
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, "dnf:test")
|
|
if err != nil || !found {
|
|
t.Fatalf("load account found=%t err=%v", found, err)
|
|
}
|
|
if premium.BlackDiamondExpireAt(account) != now.Add(30*24*time.Hour).Unix() {
|
|
t.Fatalf("read-only query mutated entitlement=%+v", account.Metadata)
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, "88")
|
|
if err != nil || !found || character.Stats["fatigue_limit"] != 188 {
|
|
t.Fatalf("read-only query mutated character=%+v found=%t err=%v", character, found, err)
|
|
}
|
|
|
|
// 过期账号:Active=false 但到期时间仍要回显(工具端要显示"已过期")。
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{
|
|
premium.MetadataKey(premium.TypeBlackDiamondEntitlement): strconv.FormatInt(now.Add(-time.Hour).Unix(), 10),
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expired, err := service.GMBlackDiamondStatus(ctx, GMBlackDiamondQuery{CharacterID: "88"})
|
|
if err != nil || expired.Active || expired.RemainingSeconds != 0 || expired.ExpiresAt != now.Add(-time.Hour).Unix() {
|
|
t.Fatalf("expired status=%+v err=%v", expired, err)
|
|
}
|
|
|
|
// 按角色名查(工具端用名字时)与找不到角色。
|
|
if byName, err := service.GMBlackDiamondStatus(ctx, GMBlackDiamondQuery{CharacterName: "Receiver"}); err != nil || byName.CharacterID != "88" {
|
|
t.Fatalf("query by name status=%+v err=%v", byName, err)
|
|
}
|
|
if _, err := service.GMBlackDiamondStatus(ctx, GMBlackDiamondQuery{CharacterID: "999"}); !errors.Is(err, ErrGMCharacterNotFound) {
|
|
t.Fatalf("missing character error=%v", err)
|
|
}
|
|
if _, err := service.GMBlackDiamondStatus(ctx, GMBlackDiamondQuery{}); !errors.Is(err, ErrGMRequestInvalid) {
|
|
t.Fatalf("empty query error=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestGMUnlockDungeonsSendsCurrentPVFPermissionSnapshotToOnlineCharacter(t *testing.T) {
|
|
ctx := context.Background()
|
|
source := bridgeDungeonPVF(false)
|
|
source["dungeon/test.dgn"] = "[name]\n`Synthetic Dungeon`\n" +
|
|
"[difficulty level]\n0 1 2 3 4\n" +
|
|
"[maze info]\n[size]\n1 1\n[greed]\n`A`\n" +
|
|
"[map specification]\n`map` 0 0 100\n[start map]\n0 0\n[boss map]\n0 0\n"
|
|
table, resolver, _ := loadBridgeDungeonStaticData(t, source)
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Job: "2", Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
service := &Service{
|
|
options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
worldMapTable: table,
|
|
worldMapResolver: resolver,
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: {
|
|
conn: connection, connID: "gm-dungeon-permission-online", accountID: "dnf:test", selectedCharacterID: 88,
|
|
}},
|
|
}
|
|
result, err := service.GMUnlockDungeons(ctx, GMDungeonUnlockRequest{
|
|
CharacterID: "88",
|
|
Dungeons: map[string]int{"700": 4},
|
|
})
|
|
if err != nil || !result.Online || !result.Refreshed || result.Count != 1 || result.RefreshError != "" {
|
|
t.Fatalf("GMUnlockDungeons() = %+v error=%v", result, err)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
// The current-PVF projection clamps the persisted request to the dungeon's
|
|
// client-visible maximum instead of echoing an unproved GM request byte.
|
|
want := buildCurrentDungeonResourceStateEntriesBody([]currentDungeonResourceStateEntry{{DungeonID: 700, State: 3}})
|
|
if len(packets) != 1 || packets[0].Header.MsgID != currentDungeonResourceStateMsgID ||
|
|
packets[0].Header.Classification != 0 || !bytes.Equal(packets[0].Body[3:], want) {
|
|
t.Fatalf("GM dungeon permission native packets = %+v want_body=%x", packets, want)
|
|
}
|
|
}
|
|
|
|
func TestGMRepairHappyTokenCeraVoucherMovesLegacyMaterialIntoAccountWallet(t *testing.T) {
|
|
ctx := context.Background()
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:test",
|
|
Metadata: map[string]string{
|
|
"account_cera": "700",
|
|
"account_token_cera": "100",
|
|
"account_happy_token_cera": "1",
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
|
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "88",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:121": {ItemID: 2681917, Count: 99999},
|
|
"0:122": {ItemID: 3227, Count: 5},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection := &bufferConn{}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:test", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
gameSessions: map[uint16]*gameSession{88: {
|
|
conn: connection, connID: "gm-happy-token-repair", selectedCharacterID: 88,
|
|
}},
|
|
}
|
|
|
|
result, err := service.GMRepairHappyTokenCeraVoucher(ctx, GMWalletRequest{CharacterID: "88"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Granted != 99999 || result.Cera != 700 || result.TokenCera != 100 || result.HappyTokenCera != 100000 || !result.Refreshed {
|
|
t.Fatalf("repair result = %+v", result)
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(ctx, "88")
|
|
if err != nil || !found {
|
|
t.Fatalf("inventory found=%t err=%v", found, err)
|
|
}
|
|
if _, exists := inventory.Slots["0:121"]; exists || inventory.Slots["0:122"].Count != 5 {
|
|
t.Fatalf("repaired inventory = %+v", inventory.Slots)
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, "dnf:test")
|
|
if err != nil || !found || account.Metadata["account_happy_token_cera"] != "100000" {
|
|
t.Fatalf("repaired account found=%t err=%v metadata=%v", found, err, account.Metadata)
|
|
}
|
|
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
|
|
if len(packets) != 2 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
|
|
packets[1].Header.MsgID != currentCeraShopUpdateMsgID ||
|
|
len(packets[1].Body) < 3 || !bytes.Equal(packets[1].Body[3:], buildCurrentCeraShopWalletBody(700, 100, 100000)) {
|
|
t.Fatalf("repair refresh packets = %+v", packets)
|
|
}
|
|
}
|