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

1415 lines
46 KiB
Go

package dungeon
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfhonor "longheng.io/server/internal/modules/dnf/honor"
"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 TestOwnerGrantCardRewardCommitsWalletAndInventoryTogether(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 100)
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: 7001, Count: 2},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
expireAt := time.Unix(2000, 0).UTC()
updatedAt := time.Unix(1000, 0).UTC()
result, err := owner.GrantCardReward(ctx, CardRewardCommand{
CharacterID: "19",
MainSlots: 3,
Bundle: CardRewardBundle{
Gold: 50,
Items: []CardItemReward{{
Stack: dnfrepo.ItemStack{ItemID: 7001, Count: 3},
Stackable: true,
ExpireAt: expireAt,
}},
},
UpdatedAt: updatedAt,
Project: func(stack dnfrepo.ItemStack, expiration time.Time) (dnfrepo.ItemStack, error) {
stack.ExpireAt = expiration
return stack, nil
},
})
if err != nil {
t.Fatalf("GrantCardReward: %v", err)
}
if result.GoldBefore != 100 || result.GoldAfter != 150 ||
len(result.ItemSlots) != 1 || result.ItemSlots[0] != 0 {
t.Fatalf("result = %+v", result)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if character.Stats["gold"] != 150 ||
inventory.Slots["0:0"].Count != 5 ||
!inventory.Slots["0:0"].ExpireAt.Equal(expireAt) {
t.Fatalf("character=%+v inventory=%+v", character, inventory)
}
}
// The paid card row charges the commission op35 printed before it credits the
// bundle, so the balance moves by reward-minus-cost in one transaction.
func TestOwnerGrantCardRewardSpendsPaidCardCommission(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 100)
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
command := CardRewardCommand{
CharacterID: "19",
CompletionKey: "paid-card-flip",
MainSlots: 3,
Cost: 30,
Bundle: CardRewardBundle{Gold: 50},
UpdatedAt: time.Unix(1000, 0).UTC(),
}
result, err := owner.GrantCardReward(ctx, command)
if err != nil {
t.Fatalf("GrantCardReward: %v", err)
}
if result.GoldBefore != 100 || result.GoldSpent != 30 || result.GoldAfter != 120 {
t.Fatalf("result = %+v, want before=100 spent=30 after=120", result)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats["gold"] != 120 {
t.Fatalf("gold = %d, want 120", character.Stats["gold"])
}
// The receipt has to replay the charged amount, not just the credited one:
// a retry that spends a second time charges the player twice for one flip.
replayed, err := owner.GrantCardReward(ctx, command)
if err != nil {
t.Fatalf("replay: %v", err)
}
if !replayed.Replayed || replayed.GoldSpent != 30 || replayed.GoldAfter != 120 {
t.Fatalf("replay = %+v, want replayed spent=30 after=120", replayed)
}
character, _, err = repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("reload character: %v", err)
}
if character.Stats["gold"] != 120 {
t.Fatalf("replay charged twice: gold = %d, want 120", character.Stats["gold"])
}
}
// A flip the character cannot pay for must be refused before anything mutates,
// matching 86JP's TryBuildRewardPlan, which rejects the plan up front.
func TestOwnerGrantCardRewardRefusesUnpayableCommission(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 20)
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
if _, err = owner.GrantCardReward(ctx, CardRewardCommand{
CharacterID: "19",
MainSlots: 3,
Cost: 30,
Bundle: CardRewardBundle{Gold: 50},
UpdatedAt: time.Unix(1000, 0).UTC(),
}); !errors.Is(err, ErrCardRewardGoldShort) {
t.Fatalf("err = %v, want ErrCardRewardGoldShort", err)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats["gold"] != 20 {
t.Fatalf("refused flip mutated gold: %d, want 20", character.Stats["gold"])
}
}
func TestOwnerGrantCardRewardRoutesItemToMailboxWhenInventoryIsFull(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 100)
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: 1, Count: 1},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, _ := NewOwner(repos)
result, err := owner.GrantCardReward(ctx, CardRewardCommand{
CharacterID: "19",
MainSlots: 1,
Bundle: CardRewardBundle{
Gold: 50,
Items: []CardItemReward{{
Stack: dnfrepo.ItemStack{ItemID: 2, Count: 1},
}},
},
})
if err != nil {
t.Fatalf("GrantCardReward: %v", err)
}
if result.OverflowMailID == "" || len(result.ItemSlots) != 1 || result.ItemSlots[0] != -1 {
t.Fatalf("result = %+v", result)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats["gold"] != 150 {
t.Fatalf("gold = %d, want 150", character.Stats["gold"])
}
mailbox, found, err := repos.Mailbox.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load mailbox found=%t err=%v", found, err)
}
mail := mailbox.Mails[result.OverflowMailID]
if len(mail.Attachments) != 1 || mail.Attachments[0].ItemID != 2 || mail.Attachments[0].Count != 1 {
t.Fatalf("overflow mail = %+v", mail)
}
if mail.SenderName != "系统" || mail.Title != "背包已满:通关奖励" ||
mail.Body != "背包空间不足,通关奖励已通过邮件发送。请清理对应道具分页后领取。" {
t.Fatalf("overflow mail prompt = %+v", mail)
}
}
func TestOwnerGrantCardRewardDurableReceiptReplaysIndeterminateMailboxCommit(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 100)
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:0": {ItemID: 1, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
wantErr := errors.New("mailbox transaction committed but result was lost")
indeterminate := &commitThenErrorMailboxAssetUnitOfWork{
base: repos.MailboxAssets,
failOnce: true,
err: wantErr,
}
repos.MailboxAssets = indeterminate
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
command := CardRewardCommand{
CharacterID: "19",
CompletionKey: "altar-clear/run-1:card-plan:stable-plan-id",
MainSlots: 2,
Bundle: CardRewardBundle{
Gold: 50,
Items: []CardItemReward{
{Stack: dnfrepo.ItemStack{ItemID: 2, Count: 1}},
{Stack: dnfrepo.ItemStack{ItemID: 3, Count: 1}},
},
},
UpdatedAt: time.Unix(1000, 0).UTC(),
}
if _, err := owner.GrantCardReward(ctx, command); !errors.Is(err, wantErr) {
t.Fatalf("indeterminate first commit error=%v", err)
}
character, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found || character.Stats["gold"] != 150 {
t.Fatalf("committed character found=%t err=%v stats=%v", found, err, character.Stats)
}
inventory, found, err := repos.Inventory.Load(ctx, "19")
if err != nil || !found || inventory.Slots["0:1"].ItemID != 2 || inventory.Slots["0:1"].Count != 1 {
t.Fatalf("committed inventory found=%t err=%v slots=%+v", found, err, inventory.Slots)
}
mailbox, found, err := repos.Mailbox.Load(ctx, "19")
if err != nil || !found || len(mailbox.Mails) != 1 {
t.Fatalf("committed mailbox found=%t err=%v mails=%+v", found, err, mailbox.Mails)
}
replayed, err := owner.GrantCardReward(ctx, command)
if err != nil {
t.Fatal(err)
}
if !replayed.Replayed || replayed.GoldBefore != 100 || replayed.GoldAfter != 150 ||
len(replayed.ItemSlots) != 2 || replayed.ItemSlots[0] != 1 || replayed.ItemSlots[1] != -1 ||
replayed.OverflowMailID == "" {
t.Fatalf("replayed result=%+v", replayed)
}
character, _, err = repos.Character.Load(ctx, "19")
if err != nil || character.Stats["gold"] != 150 {
t.Fatalf("replay duplicated gold: character=%+v err=%v", character, err)
}
inventory, _, err = repos.Inventory.Load(ctx, "19")
if err != nil || inventory.Slots["0:1"].Count != 1 {
t.Fatalf("replay duplicated inventory item: inventory=%+v err=%v", inventory, err)
}
mailbox, _, err = repos.Mailbox.Load(ctx, "19")
if err != nil || len(mailbox.Mails) != 1 || indeterminate.calls != 2 {
t.Fatalf("replay duplicated mailbox or skipped transaction: mails=%+v calls=%d err=%v",
mailbox.Mails, indeterminate.calls, err)
}
}
type commitThenErrorMailboxAssetUnitOfWork struct {
base dnfrepo.MailboxAssetUnitOfWork
failOnce bool
err error
calls int
}
func (unit *commitThenErrorMailboxAssetUnitOfWork) WithinMailboxAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(
dnfrepo.AccountRepository,
dnfrepo.AccountInventoryRepository,
dnfrepo.CharacterRepository,
dnfrepo.InventoryRepository,
dnfrepo.MailboxRepository,
) error,
) error {
unit.calls++
err := unit.base.WithinMailboxAssets(ctx, accountID, characterID, apply)
if err == nil && unit.failOnce {
unit.failOnce = false
return unit.err
}
return err
}
var _ dnfrepo.MailboxAssetUnitOfWork = (*commitThenErrorMailboxAssetUnitOfWork)(nil)
func TestOwnerGrantPickupGoldUsesCharacterAssetTransaction(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 100)
owner, _ := NewOwner(repos)
result, err := owner.GrantPickupGold(ctx, GoldPickupCommand{
CharacterID: "19",
Amount: 25,
})
if err != nil {
t.Fatalf("GrantPickupGold: %v", err)
}
if result.GoldBefore != 100 || result.GoldAfter != 125 {
t.Fatalf("result = %+v", result)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats["gold"] != 125 {
t.Fatalf("gold = %d, want 125", character.Stats["gold"])
}
}
func TestOwnerGrantPickupItemOwnsPlacementAndPersistence(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:3": {ItemID: 7001, Count: 2},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, _ := NewOwner(repos)
result, err := owner.GrantPickupItem(ctx, PickupItemCommand{
CharacterID: "19",
Placement: PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: 7001,
Kind: PickupItemStackable,
StackLimit: 10,
SlotStart: 65,
SlotEnd: 120,
PreferQuickSlot: true,
},
Amount: 3,
BuildNew: func(int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 7001, Count: 3}, nil
},
},
Finalize: func(_ int16, stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) {
stack.RawEntry = []byte{5}
return stack, nil
},
})
if err != nil {
t.Fatalf("GrantPickupItem: %v", err)
}
if result.Slot != 3 || result.Stack.Count != 5 {
t.Fatalf("result = %+v", result)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if inventory.Slots["0:3"].Count != 5 ||
len(inventory.Slots["0:3"].RawEntry) != 1 ||
inventory.Slots["0:3"].RawEntry[0] != 5 {
t.Fatalf("inventory = %+v", inventory)
}
}
func TestPlacePickupItemTreatsZeroStackLimitAsUnlimited(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
"0:65": {ItemID: 7001, Count: 2},
}}
slot, stack, err := PlacePickupItem(&record, PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: 7001,
Kind: PickupItemStackable,
StackLimit: 0,
SlotStart: 65,
SlotEnd: 120,
},
Amount: 3,
BuildNew: func(int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 7001, Count: 3}, nil
},
})
if err != nil {
t.Fatalf("PlacePickupItem: %v", err)
}
if slot != 65 || stack.Count != 5 || record.Slots["0:65"].Count != 5 || len(record.Slots) != 1 {
t.Fatalf("slot=%d stack=%+v inventory=%+v", slot, stack, record.Slots)
}
}
func TestOwnerGrantTutorialRewardIsAtomicAndIdempotent(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedDungeonOwnerCharacter(t, ctx, repos, 0)
expireAt := time.Unix(3000, 0).UTC()
owner, _ := NewOwner(repos)
command := TutorialRewardCommand{
CharacterID: "19",
Progress: 38,
Rewards: []TutorialItemReward{{
Progress: 38,
ItemID: 8001,
Count: 2,
Consumable: true,
StackLimit: 10,
SlotStart: 65,
SlotEnd: 120,
ExpireAt: expireAt,
PVFPath: "stackable/test.stk",
StackableType: "[waste]",
}},
Project: func(stack dnfrepo.ItemStack, expiration time.Time) (dnfrepo.ItemStack, error) {
stack.ExpireAt = expiration
return stack, nil
},
}
result, err := owner.GrantTutorialReward(ctx, command)
if err != nil {
t.Fatalf("GrantTutorialReward: %v", err)
}
if !result.Granted || len(result.Rows) != 1 || result.Rows[0].Slot != 3 {
t.Fatalf("result = %+v", result)
}
replay, err := owner.GrantTutorialReward(ctx, command)
if err != nil {
t.Fatalf("replay GrantTutorialReward: %v", err)
}
if replay.Granted || len(replay.Rows) != 0 {
t.Fatalf("replay = %+v", replay)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if character.Stats[TutorialRewardMarker(38)] != 1 ||
inventory.Slots["0:3"].Count != 2 ||
!inventory.Slots["0:3"].ExpireAt.Equal(expireAt) {
t.Fatalf("character=%+v inventory=%+v", character, inventory)
}
}
func TestOwnerConsumeReviveCoinRetainsWalletSlotAtZero(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:1": {ItemID: 1, Count: 1},
"0:5": {ItemID: 1, Count: 2},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, _ := NewOwner(repos)
result, err := owner.ConsumeReviveCoin(ctx, ReviveCoinCommand{
CharacterID: "19",
ItemID: 1,
WalletSlot: 1,
AllowFree: true,
Project: func(_ int16, stack dnfrepo.ItemStack) (dnfrepo.ItemStack, error) {
stack.RawEntry = []byte{byte(stack.Count)}
return stack, nil
},
})
if err != nil {
t.Fatalf("ConsumeReviveCoin: %v", err)
}
if !result.Consumed || result.FreeRevive || result.Slot != 1 ||
result.CountAfter != 0 || result.Removed {
t.Fatalf("result = %+v", result)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if inventory.Slots["0:1"].Count != 0 ||
inventory.Slots["0:5"].Count != 2 ||
inventory.Slots["0:1"].Extra["last_consume_source"] != "dungeon_use_coin_revive" {
t.Fatalf("inventory = %+v", inventory)
}
}
func TestOwnerMutateOwnedInventoryValidatesCharacterAccount(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:5": {ItemID: 9001, Count: 2},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, _ := NewOwner(repos)
err := owner.MutateOwnedInventory(ctx, OwnedInventoryMutationCommand{
AccountID: "account-1",
CharacterID: "19",
Apply: func(slots map[string]dnfrepo.ItemStack) (bool, error) {
stack := slots["0:5"]
stack.Count--
slots["0:5"] = stack
return true, nil
},
})
if err != nil {
t.Fatalf("MutateOwnedInventory: %v", err)
}
inventory, _, err := repos.Inventory.Load(ctx, "19")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if inventory.Slots["0:5"].Count != 1 {
t.Fatalf("inventory = %+v", inventory)
}
err = owner.MutateOwnedInventory(ctx, OwnedInventoryMutationCommand{
AccountID: "account-2",
CharacterID: "19",
Apply: func(map[string]dnfrepo.ItemStack) (bool, error) {
return true, nil
},
})
if !errors.Is(err, ErrCharacterNotFound) {
t.Fatalf("wrong account error = %v, want %v", err, ErrCharacterNotFound)
}
}
func TestOwnerCompleteTutorialPersistsNextLoginState(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
Stats: map[string]int64{
"tutorial_completed": 0,
"town_id": 99,
},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
owner, _ := NewOwner(repos)
command := TutorialCompletionCommand{
CharacterID: "19",
CompletedKey: "tutorial_completed",
Completed: 1,
NextLogin: map[string]int64{
"town_id": 1,
"area_id": 2,
},
}
result, err := owner.CompleteTutorial(ctx, command)
if err != nil {
t.Fatalf("CompleteTutorial: %v", err)
}
if result.Previous != 0 || !result.Changed {
t.Fatalf("result = %+v", result)
}
replay, err := owner.CompleteTutorial(ctx, command)
if err != nil {
t.Fatalf("replay CompleteTutorial: %v", err)
}
if replay.Previous != 1 || replay.Changed {
t.Fatalf("replay = %+v", replay)
}
character, _, err := repos.Character.Load(ctx, "19")
if err != nil {
t.Fatalf("load character: %v", err)
}
if character.Stats["tutorial_completed"] != 1 ||
character.Stats["town_id"] != 1 ||
character.Stats["area_id"] != 2 {
t.Fatalf("character = %+v", character)
}
}
func TestOwnerCommitSettlementAdvancesAndReplaysReceipt(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatalf("load tables: %v", err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{LuckyStarMetadataKey: "398"},
}); err != nil {
t.Fatalf("seed account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 1,
Stats: map[string]int64{"exp": 90},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Points: dnfrepo.SkillPointState{
TotalSP: 10, RemainingSP: 10, SyncedLevel: 1,
},
}); err != nil {
t.Fatalf("seed skill: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
owner, _ := NewOwner(repos)
result, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "run-1",
Tables: tables,
Experience: 20,
RecommendedDungeonClear: true,
RentalPointAward: 1,
RentalPointLimit: 400,
})
if err != nil {
t.Fatalf("CommitSettlement: %v", err)
}
if result.Replayed || result.Character.Level != 2 ||
result.Character.Stats["exp"] != 110 ||
result.Character.Stats[adventuregroup.RecommendedDungeonClearStatKey] != 1 ||
result.ExperienceGain != 20 || result.SPGain != 30 ||
result.Skill.Points.SyncedLevel != 2 ||
!result.LuckyStarAttempted || result.LuckyStarBefore != 398 ||
result.LuckyStarApplied != 1 || result.LuckyStarAfter != 399 {
t.Fatalf("result = %+v", result)
}
account, found, err := repos.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata[LuckyStarMetadataKey] != "399" {
t.Fatalf("account after settlement=%+v found=%t err=%v", account, found, err)
}
replay, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "run-1",
Tables: tables,
Experience: 999,
RecommendedDungeonClear: true,
})
if err != nil {
t.Fatalf("replay CommitSettlement: %v", err)
}
if !replay.Replayed || replay.ExperienceGain != 20 || replay.SPGain != 30 ||
replay.Character.Level != 2 || replay.Character.Stats["exp"] != 110 ||
replay.Character.Stats[adventuregroup.RecommendedDungeonClearStatKey] != 1 ||
!replay.LuckyStarAttempted || replay.LuckyStarBefore != 398 ||
replay.LuckyStarApplied != 1 || replay.LuckyStarAfter != 399 {
t.Fatalf("replay = %+v", replay)
}
account, found, err = repos.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata[LuckyStarMetadataKey] != "399" {
t.Fatalf("account after replay=%+v found=%t err=%v", account, found, err)
}
for _, test := range []struct {
completionKey string
before uint32
applied uint32
after uint32
}{
{completionKey: "run-2", before: 399, applied: 1, after: 400},
{completionKey: "run-3", before: 400, applied: 0, after: 400},
} {
capped, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: test.completionKey,
Tables: tables,
Experience: 1,
RentalPointAward: 1,
RentalPointLimit: 400,
})
if err != nil || !capped.LuckyStarAttempted || capped.LuckyStarBefore != test.before ||
capped.LuckyStarApplied != test.applied || capped.LuckyStarAfter != test.after {
t.Fatalf("%s settlement=%+v err=%v", test.completionKey, capped, err)
}
}
account, found, err = repos.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata[LuckyStarMetadataKey] != "400" {
t.Fatalf("account at cap=%+v found=%t err=%v", account, found, err)
}
}
func TestPlanSettlementProgressionRejectsCapAboveAbsoluteLevel90(t *testing.T) {
tables, err := progression.Load(context.Background(), settlementTestSource())
if err != nil {
t.Fatal(err)
}
_, err = PlanSettlementProgressionAtCap(
tables,
1,
0,
100,
dnfrepo.SkillPointState{SyncedLevel: 1},
progression.MaximumCharacterLevel+1,
)
if !errors.Is(err, ErrSettlementStateInvalid) {
t.Fatalf("PlanSettlementProgressionAtCap() error = %v, want ErrSettlementStateInvalid", err)
}
}
func TestParseLuckyStarsDistinguishesMissingFromCorruptMetadata(t *testing.T) {
for _, test := range []struct {
name string
raw string
want uint32
}{
{name: "missing", raw: "", want: 0},
{name: "whitespace", raw: " \t\r\n", want: 0},
{name: "zero", raw: "0", want: 0},
{name: "value", raw: " 399 ", want: 399},
} {
t.Run(test.name, func(t *testing.T) {
got, err := parseLuckyStars(test.raw)
if err != nil || got != test.want {
t.Fatalf("parseLuckyStars(%q)=%d err=%v want=%d", test.raw, got, err, test.want)
}
})
}
for _, raw := range []string{"invalid", "-1", "4294967296"} {
if got, err := parseLuckyStars(raw); err == nil {
t.Fatalf("parseLuckyStars(%q)=%d, want error", raw, got)
}
}
}
func TestOwnerCommitSettlementCorruptLuckyStarMetadataRollsBackAndRetries(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{LuckyStarMetadataKey: "corrupt"},
}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 1,
Stats: map[string]int64{"exp": 90},
}); err != nil {
t.Fatal(err)
}
initialPoints := dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: initialPoints}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
command := SettlementCommand{
AccountID: "account-1", CharacterID: "19", CompletionKey: "corrupt-wallet-run",
Tables: tables, Experience: 20, RecommendedDungeonClear: true,
RentalPointAward: 1, RentalPointLimit: 400,
}
if _, err := owner.CommitSettlement(ctx, command); !errors.Is(err, ErrSettlementStateInvalid) {
t.Fatalf("CommitSettlement corrupt metadata error=%v", err)
}
character, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found || character.Level != 1 || character.Stats["exp"] != 90 ||
character.Stats[adventuregroup.RecommendedDungeonClearStatKey] != 0 ||
character.Stats[settlementReceiptPrefix+"version"] != 0 {
t.Fatalf("rolled-back character=%+v found=%t err=%v", character, found, err)
}
skill, found, err := repos.Skill.Load(ctx, "19")
if err != nil || !found || skill.Points != initialPoints {
t.Fatalf("rolled-back skill=%+v found=%t err=%v", skill.Points, found, err)
}
account, found, err := repos.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata[LuckyStarMetadataKey] != "corrupt" {
t.Fatalf("rolled-back account=%+v found=%t err=%v", account, found, err)
}
account.Metadata[LuckyStarMetadataKey] = "398"
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
result, err := owner.CommitSettlement(ctx, command)
if err != nil {
t.Fatalf("retry CommitSettlement: %v", err)
}
if result.Replayed || result.ExperienceGain != 20 || result.LuckyStarBefore != 398 ||
result.LuckyStarApplied != 1 || result.LuckyStarAfter != 399 {
t.Fatalf("retry result=%+v", result)
}
account, _, _ = repos.Account.Load(ctx, "account-1")
if account.Metadata[LuckyStarMetadataKey] != "399" {
t.Fatalf("retry account=%+v", account)
}
}
func TestOwnerCommitSettlementZeroExperienceStillCommitsRecommendedLuckyStarOnce(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 1,
Stats: map[string]int64{"exp": 90},
}); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Points: dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
command := SettlementCommand{
AccountID: "account-1", CharacterID: "19", CompletionKey: "zero-exp-recommended-run",
Tables: tables, RecommendedDungeonClear: true,
RentalPointAward: 1, RentalPointLimit: 400,
}
result, err := owner.CommitSettlement(ctx, command)
if err != nil {
t.Fatal(err)
}
if result.ExperienceGain != 0 || result.Character.Level != 1 || result.Character.Stats["exp"] != 90 ||
result.Character.Stats[adventuregroup.RecommendedDungeonClearStatKey] != 1 ||
!result.LuckyStarAttempted || result.LuckyStarBefore != 0 || result.LuckyStarApplied != 1 ||
result.LuckyStarAfter != 1 {
t.Fatalf("zero-exp result=%+v", result)
}
replay, err := owner.CommitSettlement(ctx, command)
if err != nil || !replay.Replayed || replay.ExperienceGain != 0 || replay.LuckyStarAfter != 1 {
t.Fatalf("zero-exp replay=%+v err=%v", replay, err)
}
account, found, err := repos.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata[LuckyStarMetadataKey] != "1" {
t.Fatalf("zero-exp account=%+v found=%t err=%v", account, found, err)
}
}
func TestOwnerCommitSettlementRejectsOrdinaryZeroExperienceWithoutReceipt(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 1,
Stats: map[string]int64{"exp": 90},
}); err != nil {
t.Fatal(err)
}
initialPoints := dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: initialPoints}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1", CharacterID: "19", CompletionKey: "ordinary-zero-exp-run",
Tables: tables,
})
if !errors.Is(err, ErrSettlementStateInvalid) {
t.Fatalf("ordinary zero-exp error=%v", err)
}
character, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found || character.Stats["exp"] != 90 ||
character.Stats[settlementReceiptPrefix+"version"] != 0 {
t.Fatalf("ordinary zero-exp character=%+v found=%t err=%v", character, found, err)
}
skill, found, err := repos.Skill.Load(ctx, "19")
if err != nil || !found || skill.Points != initialPoints {
t.Fatalf("ordinary zero-exp skill=%+v found=%t err=%v", skill.Points, found, err)
}
}
func TestOwnerCommitSettlementAcceptsOptedInZeroExperience(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 1,
Stats: map[string]int64{"exp": 90},
}); err != nil {
t.Fatal(err)
}
initialPoints := dnfrepo.SkillPointState{TotalSP: 10, RemainingSP: 10, SyncedLevel: 1}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: initialPoints}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
// A raid gate is a legitimate zero-experience completion: its reward is the
// raid phase settlement. Without the opt-in the zero-experience guard rejected
// it, the whole settlement chain aborted, and every op72 afterwards answered
// settlement_result_not_ready (live 2026-09-16T13:07Z).
command := SettlementCommand{
AccountID: "account-1", CharacterID: "19", CompletionKey: "raid-gate-zero-exp-run",
Tables: tables, AllowZeroExperience: true,
}
result, err := owner.CommitSettlement(ctx, command)
if err != nil {
t.Fatalf("raid gate zero-exp settlement: %v", err)
}
if result.ExperienceGain != 0 || result.Character.Stats["exp"] != 90 {
t.Fatalf("raid gate zero-exp result=%+v", result)
}
if result.Character.Stats[settlementReceiptPrefix+"version"] != settlementReceiptVersion {
t.Fatalf("raid gate zero-exp receipt not written: %+v", result.Character.Stats)
}
replay, err := owner.CommitSettlement(ctx, command)
if err != nil || !replay.Replayed || replay.ExperienceGain != 0 {
t.Fatalf("raid gate zero-exp replay=%+v err=%v", replay, err)
}
}
func TestSettlementReceiptValuesAcceptsPreLuckyStarReceipt(t *testing.T) {
stats := map[string]int64{"exp": 110}
writeSettlementReceipt(stats, sha256.Sum256([]byte("old-run")), 20, 30, 2, 110, false, 0, 0, 0)
delete(stats, settlementReceiptPrefix+"lucky_star_attempted")
delete(stats, settlementReceiptPrefix+"lucky_star_before")
delete(stats, settlementReceiptPrefix+"lucky_star_applied")
delete(stats, settlementReceiptPrefix+"lucky_star_after")
receipt, err := settlementReceiptValues(
dnfrepo.CharacterRecord{Level: 2, Stats: stats},
dnfrepo.SkillRecord{Points: dnfrepo.SkillPointState{SyncedLevel: 2}},
)
if err != nil || receipt.ExperienceGain != 20 || receipt.SPGain != 30 || receipt.LuckyStarAttempted {
t.Fatalf("legacy receipt=%+v err=%v", receipt, err)
}
}
func TestOwnerCommitSettlementAccumulatesMaximumLevelAdventureExperienceOnce(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
honorTables, err := dnfhonor.LoadTables(settlementMemorySource{dnfhonor.TablePath: settlementHonorExpertTestDocument()})
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{},
}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 5,
Stats: map[string]int64{"exp": 900},
}); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Points: dnfrepo.SkillPointState{
TotalSP: 160, RemainingSP: 160, TotalTP: 4, RemainingTP: 4, SyncedLevel: 5,
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
config := adventuregroup.RuntimeConfig{
ShopCategories: []adventuregroup.ShopCategory{
{
Index: adventuregroup.ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 10,
PointPerExperience: 1,
},
{Index: adventuregroup.ShopPointGlory, MaxPoint: 9999},
{Index: adventuregroup.ShopPointPure},
},
Capsule: adventuregroup.CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 1,
MaximumLevel: 4,
GrantedExperience: 1,
},
}
owner, _ := NewOwner(repos)
command := SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "max-level-run",
Tables: tables,
Experience: 25,
AdventureRuntime: &config,
HonorExpertTables: honorTables,
MaximumCharacterLevel: 5,
}
result, err := owner.CommitSettlement(ctx, command)
if err != nil {
t.Fatal(err)
}
if result.Character.Level != 5 || result.Character.Stats["exp"] != 900 ||
result.HonorExpertGain != 25 ||
result.Character.Stats[dnfhonor.ExpertLevelStatKey] != 1 ||
result.Character.Stats[dnfhonor.ExpertProgressExperienceStatKey] != 15 {
t.Fatalf("result=%+v", result)
}
account, found, err := repos.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%v err=%v", found, err)
}
runtime, err := adventuregroup.ParseRuntimeState(account, config, time.Now())
if err != nil {
t.Fatal(err)
}
if account.HonorExp != 25 || runtime.ShopPoints[adventuregroup.ShopPointBrave] != 2 ||
runtime.BraveExperience != 5 || runtime.GrowthExperience != 25 {
t.Fatalf("account=%+v runtime=%+v", account, runtime)
}
command.Experience = 99
replay, err := owner.CommitSettlement(ctx, command)
if err != nil || !replay.Replayed {
t.Fatalf("replay=%+v err=%v", replay, err)
}
account, _, _ = repos.Account.Load(ctx, "account-1")
runtime, _ = adventuregroup.ParseRuntimeState(account, config, time.Now())
if account.HonorExp != 25 || runtime.GrowthExperience != 25 ||
replay.HonorExpertGain != 0 || replay.Character.Stats[dnfhonor.ExpertLevelStatKey] != 1 ||
replay.Character.Stats[dnfhonor.ExpertProgressExperienceStatKey] != 15 {
t.Fatalf("replay duplicated account=%+v runtime=%+v", account, runtime)
}
}
// TestOwnerCommitSettlementRepairsAFullHonorExpertBar pins the 2026-10-03
// operator fix at the boundary it actually broke: the dungeon clear itself.
//
// A stored HonorExpert state whose bar is already full -- progress at or above
// the current level's requirement, or any progress at the final level -- is
// refused by honor.ResolveExpert, and this settlement used to propagate that
// refusal, rolling back the WHOLE clear. The player killed the boss, got no EXP
// and no reward row, and could not leave the instance until the client was
// force-closed:
//
// "熟练度满级熟练度经验满了之后就无法再通关副本了,打完副本boss的时候
// 没有经验结算,奖励结算,直接卡在副本里面"
//
// Each shape below used to fail CommitSettlement outright. They must now settle,
// apply the whole award, and land on a state the validator accepts.
func TestOwnerCommitSettlementRepairsAFullHonorExpertBar(t *testing.T) {
// The honor test document is [1 10, 2 20]: level 0 is left at 10, level 1 at
// 20, and level 2 is the final row.
cases := []struct {
name string
storedLevel int64
storedExp int64
}{
{name: "full bar at level zero", storedLevel: 0, storedExp: 10},
{name: "bar holding more than one level", storedLevel: 0, storedExp: 304},
{name: "final level holding progress", storedLevel: 2, storedExp: 1},
{name: "level beyond the final row", storedLevel: 9, storedExp: 7},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
honorTables, err := dnfhonor.LoadTables(settlementMemorySource{dnfhonor.TablePath: settlementHonorExpertTestDocument()})
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{},
}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 5,
Stats: map[string]int64{
"exp": 900,
dnfhonor.ExpertLevelStatKey: test.storedLevel,
dnfhonor.ExpertProgressExperienceStatKey: test.storedExp,
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Points: dnfrepo.SkillPointState{
TotalSP: 160, RemainingSP: 160, TotalTP: 4, RemainingTP: 4, SyncedLevel: 5,
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
config := adventuregroup.RuntimeConfig{
ShopCategories: []adventuregroup.ShopCategory{
{Index: adventuregroup.ShopPointBrave, MaxPoint: 250, ExperiencePerPoint: 10, PointPerExperience: 1},
{Index: adventuregroup.ShopPointGlory, MaxPoint: 9999},
{Index: adventuregroup.ShopPointPure},
},
Capsule: adventuregroup.CapsuleConfig{
MinimumExperience: 10, MaximumExperience: 100, MaximumCount: 10,
MinimumLevel: 1, MaximumLevel: 4, GrantedExperience: 1,
},
}
owner, _ := NewOwner(repos)
result, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "full-honor-expert-bar-run",
Tables: tables,
Experience: 25,
AdventureRuntime: &config,
HonorExpertTables: honorTables,
MaximumCharacterLevel: 5,
})
if err != nil {
t.Fatalf("settlement refused a full expert bar, which is what leaves the player stuck in the dungeon: %v", err)
}
if result.HonorExpertGain != 25 {
t.Fatalf("HonorExpertGain=%d, want the whole 25 post-cap award", result.HonorExpertGain)
}
progress := dnfhonor.ExpertProgress{
Level: uint32(result.Character.Stats[dnfhonor.ExpertLevelStatKey]),
CurrentLevelExperience: uint64(result.Character.Stats[dnfhonor.ExpertProgressExperienceStatKey]),
}
if _, err := honorTables.ResolveExpert(progress); err != nil {
t.Fatalf("settlement persisted a state the validator still refuses: %+v: %v", progress, err)
}
persisted, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
if persisted.Stats[dnfhonor.ExpertLevelStatKey] != result.Character.Stats[dnfhonor.ExpertLevelStatKey] ||
persisted.Stats[dnfhonor.ExpertProgressExperienceStatKey] != result.Character.Stats[dnfhonor.ExpertProgressExperienceStatKey] {
t.Fatalf("persisted expert=%d:%d, returned=%d:%d",
persisted.Stats[dnfhonor.ExpertLevelStatKey], persisted.Stats[dnfhonor.ExpertProgressExperienceStatKey],
result.Character.Stats[dnfhonor.ExpertLevelStatKey], result.Character.Stats[dnfhonor.ExpertProgressExperienceStatKey])
}
if result.Character.Level != 5 || result.Character.Stats["exp"] != 900 {
t.Fatalf("the at-cap character moved: level=%d exp=%d",
result.Character.Level, result.Character.Stats["exp"])
}
})
}
}
// 未满级通关结算现在也要攒骁勇结晶(2026-09-24 口径),但胶囊槽与名誉经验不动;
// 账号行缺失时只跳过记账、结算照样落(角色经验/技能/背包都在后面写),
// 免得一行账本缺失把玩家的通关结算整个卡死。
func TestOwnerCommitSettlementCreditsCrystalsOnlyBelowCap(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
config := adventuregroup.RuntimeConfig{
ShopCategories: []adventuregroup.ShopCategory{
{
Index: adventuregroup.ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 10,
PointPerExperience: 1,
},
{Index: adventuregroup.ShopPointGlory, MaxPoint: 9999},
{Index: adventuregroup.ShopPointPure},
},
Capsule: adventuregroup.CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 1,
MaximumLevel: 4,
GrantedExperience: 1,
},
}
seed := func(t *testing.T, withAccount bool) dnfrepo.Group {
t.Helper()
repos := dnfrepomemory.NewMemoryGroup()
if withAccount {
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{},
}); err != nil {
t.Fatal(err)
}
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Level: 2,
Stats: map[string]int64{"exp": 0},
}); err != nil {
t.Fatal(err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
CharacterID: "19",
Points: dnfrepo.SkillPointState{SyncedLevel: 2},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
return repos
}
repos := seed(t, true)
owner, _ := NewOwner(repos)
result, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "below-cap-run",
Tables: tables,
Experience: 25,
AdventureRuntime: &config,
MaximumCharacterLevel: 5,
})
if err != nil {
t.Fatal(err)
}
if result.Character.Level != 2 || result.ExperienceGain != 25 {
t.Fatalf("below-cap result=%+v", result)
}
account, found, err := repos.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%v err=%v", found, err)
}
runtime, err := adventuregroup.ParseRuntimeState(account, config, time.Now())
if err != nil {
t.Fatal(err)
}
if runtime.ShopPoints[adventuregroup.ShopPointBrave] != 2 || runtime.BraveExperience != 5 {
t.Fatalf("below-cap crystals=%+v", runtime)
}
if runtime.GrowthExperience != 0 || account.HonorExp != 0 {
t.Fatalf("below-cap settlement touched capsule/honor: runtime=%+v honor=%d", runtime, account.HonorExp)
}
// 账号行缺失:只跳过记账,结算本身必须照常落盘。
repos = seed(t, false)
owner, _ = NewOwner(repos)
if _, err := owner.CommitSettlement(ctx, SettlementCommand{
AccountID: "account-1",
CharacterID: "19",
CompletionKey: "below-cap-run-no-account",
Tables: tables,
Experience: 25,
AdventureRuntime: &config,
MaximumCharacterLevel: 5,
}); err != nil {
t.Fatalf("missing account must not fail the settlement: %v", err)
}
character, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found || character.Stats["exp"] != 25 {
t.Fatalf("settlement did not persist below-cap experience: %+v err=%v", character, err)
}
}
func settlementHonorExpertTestDocument() string {
return `[grade]
1 ` + "`effect.ani` `medal.img` 0 `icon.img` 0" + ` 1 0
[/grade]
[maxexp on maxlevel]
1
[expert info]
[grade info]
0 ` + "`challenger`" + `
[min lv]
0
[max lv]
0
[/grade info]
[grade info]
1 ` + "`veteran`" + `
[medal img]
` + "`expert-medal.img`" + ` 0
[icon img]
` + "`expert-icon.img`" + ` 0
[min lv]
1
[max lv]
-1
[/grade info]
[/expert info]
[honor expert exp table]
1 10 2 20
[/honor expert exp table]
`
}
func seedDungeonOwnerCharacter(t *testing.T, ctx context.Context, repos dnfrepo.Group, gold int64) {
t.Helper()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
Stats: map[string]int64{"gold": gold},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
}
type settlementMemorySource map[string]string
func (s settlementMemorySource) ReadText(path string) (string, error) {
value, ok := s[path]
if !ok {
return "", fmt.Errorf("missing test PVF path %s", path)
}
return value, nil
}
func settlementTestSource() settlementMemorySource {
return settlementMemorySource{
progression.ExperienceTablePath: "100 250 500 900\n",
progression.SkillPointTablePath: "[sp table]\n1 10\n2 30\n3 30\n4 40\n5 50\n[/sp table]\n[tp table]\n3 2\n4 1\n5 1\n[/tp table]\n",
progression.QuestParameterPath: `[difficulty]
` + "`N` 100\n`E` 200\n" + `[/difficulty]
[exp reward table]
100 -1
200 -1
300 -1
400 -1
500 -1
[gold reward table]
0 10
[green level penalty]
80
[grey level penalty]
30
`,
}
}