Files
90CNServer/server/internal/modules/dnf/vending/owner_test.go
T
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

314 lines
11 KiB
Go

package vending
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerUseDebitsExactClientMaterialSlotAndGrantsRewardAtomically(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
"0:140": {ItemID: 2749933, Count: 9},
"0:142": {ItemID: 2749933, Count: 2, RawEntry: make([]byte, 61)},
})
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
updatedAt := time.Unix(1700000000, 0).UTC()
result, err := owner.Use(ctx, Command{
AccountID: "dnf:1",
CharacterID: "77",
CompletionKey: "vending/session-1/request-1",
MaterialSlot: 142,
MaterialItemID: 2749933,
MaterialCount: 1,
RewardItemID: 2749957,
RewardCount: 2,
UpdatedAt: updatedAt,
Project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:150"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return []int16{150}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.MaterialSlot != 142 || result.MaterialItemID != 2749933 || result.MaterialCount != 1 ||
result.MaterialRemaining != 1 || result.MaterialRemoved || result.RewardItemID != 2749957 ||
result.RewardCount != 2 || len(result.TouchedSlots) != 1 || result.TouchedSlots[0] != 150 ||
len(result.RewardSnapshots) != 1 || result.RewardSnapshots[0].Slot != 150 ||
result.RewardSnapshots[0].Stack.ItemID != 2749957 || result.RewardSnapshots[0].Stack.Count != 2 || result.Replayed {
t.Fatalf("result=%+v", result)
}
result.RewardSnapshots[0].Stack.Count = 99
inventory := loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].Count != 9 || inventory.Slots["0:142"].Count != 1 ||
inventory.Slots["0:150"].ItemID != 2749957 || inventory.Slots["0:150"].Count != 2 {
t.Fatalf("inventory=%+v", inventory.Slots)
}
if got := inventory.Slots["0:142"].Extra["amount_or_count"]; got != "1" {
t.Fatalf("material count projection=%q", got)
}
character, found, err := repositories.Character.Load(ctx, "77")
if err != nil || !found || character.Stats[receiptPrefix+"version"] != receiptVersion {
t.Fatalf("character found=%t err=%v stats=%+v", found, err, character.Stats)
}
}
func TestOwnerUseRemovesDepletedMaterialSlot(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
"0:140": {ItemID: 7454, Count: 1},
})
owner, _ := NewOwner(repositories)
result, err := owner.Use(ctx, vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return []int16{151}, nil
}))
if err != nil {
t.Fatal(err)
}
if !result.MaterialRemoved || result.MaterialRemaining != 0 {
t.Fatalf("result=%+v", result)
}
if inventory := loadVendingInventory(t, ctx, repositories); inventory.Slots["0:140"].ItemID != 0 {
t.Fatalf("depleted material remains: %+v", inventory.Slots)
}
}
func TestOwnerUseRollsBackMissingMaterialCapacityAndProjectionErrors(t *testing.T) {
wantProjectionErr := errors.New("raw reward projection failed")
tests := []struct {
name string
material dnfrepo.ItemStack
project Projector
want error
wantCalled bool
}{
{
name: "missing material",
material: dnfrepo.ItemStack{ItemID: 7454, Count: 0},
project: func(*dnfrepo.InventoryRecord, uint32, uint32) ([]int16, error) {
t.Fatal("projector called without material")
return nil, nil
},
want: ErrMaterialMissing,
},
{
name: "capacity",
material: dnfrepo.ItemStack{ItemID: 7454, Count: 2},
project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return nil, ErrInventoryFull
},
want: ErrInventoryFull,
},
{
name: "projection",
material: dnfrepo.ItemStack{ItemID: 7454, Count: 2},
project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return nil, wantProjectionErr
},
want: wantProjectionErr,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{"0:140": test.material})
owner, _ := NewOwner(repositories)
_, err := owner.Use(ctx, vendingCommand(test.project))
if !errors.Is(err, test.want) {
t.Fatalf("error=%v want=%v", err, test.want)
}
inventory := loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].ItemID != test.material.ItemID ||
inventory.Slots["0:140"].Count != test.material.Count {
t.Fatalf("material changed after failure: before=%+v after=%+v", test.material, inventory.Slots["0:140"])
}
if _, found := inventory.Slots["0:151"]; found {
t.Fatalf("reward leaked after failure: %+v", inventory.Slots)
}
character, _, err := repositories.Character.Load(ctx, "77")
if err != nil || character.Stats[receiptPrefix+"version"] != 0 {
t.Fatalf("receipt leaked after failure: stats=%+v err=%v", character.Stats, err)
}
})
}
}
func TestOwnerUseRejectsWrongAccountAndWrongExplicitSlot(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
"0:140": {ItemID: 7454, Count: 2},
"0:142": {ItemID: 7454, Count: 2},
})
owner, _ := NewOwner(repositories)
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return []int16{151}, nil
})
command.AccountID = "dnf:2"
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("account error=%v", err)
}
command.AccountID = "dnf:1"
command.MaterialSlot = 141
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrMaterialMissing) {
t.Fatalf("slot error=%v", err)
}
inventory := loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].Count != 2 || inventory.Slots["0:142"].Count != 2 {
t.Fatalf("wrong slot fallback consumed another stack: %+v", inventory.Slots)
}
}
func TestOwnerUseReceiptReplaysCommitThenWrapperErrorExactlyOnce(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
"0:140": {ItemID: 7454, Count: 2},
})
wantErr := errors.New("transaction committed but response was lost")
indeterminate := &commitThenErrorCharacterAssets{
base: repositories.CharacterAssets,
failOnce: true,
err: wantErr,
}
repositories.CharacterAssets = indeterminate
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
projectCalls := 0
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
projectCalls++
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return []int16{151}, nil
})
command.CompletionKey = "vending/session-77/request-0001"
if _, err := owner.Use(ctx, command); !errors.Is(err, wantErr) {
t.Fatalf("first error=%v", err)
}
inventory := loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].Count != 1 {
t.Fatalf("inner commit did not persist exactly once: %+v", inventory.Slots)
}
replayed, err := owner.Use(ctx, command)
if err != nil {
t.Fatal(err)
}
if !replayed.Replayed || replayed.MaterialSlot != 140 || replayed.MaterialRemaining != 1 ||
replayed.MaterialRemoved || replayed.RewardItemID != 31 || replayed.RewardCount != 1 ||
len(replayed.TouchedSlots) != 1 || replayed.TouchedSlots[0] != 151 ||
len(replayed.RewardSnapshots) != 1 || replayed.RewardSnapshots[0].Slot != 151 ||
replayed.RewardSnapshots[0].Stack.ItemID != 31 || replayed.RewardSnapshots[0].Stack.Count != 1 {
t.Fatalf("replayed=%+v", replayed)
}
inventory = loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].Count != 1 ||
projectCalls != 1 || indeterminate.calls != 2 {
t.Fatalf("replay duplicated mutation: slots=%+v projectCalls=%d txCalls=%d", inventory.Slots, projectCalls, indeterminate.calls)
}
}
func TestOwnerUseSameReceiptKeyRejectsChangedRewardAuthority(t *testing.T) {
ctx := context.Background()
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
"0:140": {ItemID: 7454, Count: 2},
})
owner, _ := NewOwner(repositories)
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
return []int16{151}, nil
})
command.CompletionKey = "stable-key"
if _, err := owner.Use(ctx, command); err != nil {
t.Fatal(err)
}
command.RewardItemID = 44
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrReceiptInvalid) {
t.Fatalf("changed authority replay error=%v", err)
}
inventory := loadVendingInventory(t, ctx, repositories)
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].ItemID != 31 {
t.Fatalf("changed replay mutated inventory: %+v", inventory.Slots)
}
}
func vendingCommand(project Projector) Command {
return Command{
AccountID: "dnf:1",
CharacterID: "77",
CompletionKey: "vending/test-request",
MaterialSlot: 140,
MaterialItemID: 7454,
MaterialCount: 1,
RewardItemID: 31,
RewardCount: 1,
Project: project,
}
}
func vendingOwnerTestRepositories(
t *testing.T,
ctx context.Context,
slots map[string]dnfrepo.ItemStack,
) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:1",
Stats: map[string]int64{"gold": 100},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: slots,
}); err != nil {
t.Fatal(err)
}
return repositories
}
func loadVendingInventory(t *testing.T, ctx context.Context, repositories dnfrepo.Group) dnfrepo.InventoryRecord {
t.Helper()
inventory, found, err := repositories.Inventory.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
return inventory
}
type commitThenErrorCharacterAssets struct {
base dnfrepo.CharacterAssetUnitOfWork
failOnce bool
err error
calls int
}
func (unit *commitThenErrorCharacterAssets) WithinCharacterAssets(
ctx context.Context,
characterID string,
apply func(dnfrepo.CharacterRepository, dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
) error {
unit.calls++
err := unit.base.WithinCharacterAssets(ctx, characterID, apply)
if err == nil && unit.failOnce {
unit.failOnce = false
return unit.err
}
return err
}
var _ dnfrepo.CharacterAssetUnitOfWork = (*commitThenErrorCharacterAssets)(nil)