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,973 @@
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)
}
}