Files
90CNServer/server/internal/modules/dnf/epicbook/ledger_incremental_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

164 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package epicbook
import (
"context"
"errors"
"math"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 拾取落库必须优先走"单行增量"。
//
// 这条断言是线上死锁事故(同一账号并发拾取 8 件碎片,7 件以 1213 回滚)的回归
// 闸门:把 AddFragment 改回"锁读整本账 + 删光重写",increments 会变成 0、
// loads/saves 会变成非 0,用例立刻失败。
func TestAddFragmentPrefersInPlaceIncrement(t *testing.T) {
store := &recordingIncrementStore{balances: map[uint32]uint32{}}
uow := &stubEpicBookCraftUnitOfWork{store: store}
balance, err := AddFragment(context.Background(), uow, "account-1", "7", 101020256, 3, testTime())
if err != nil || balance != 3 {
t.Fatalf("AddFragment() balance=%d error=%v", balance, err)
}
if store.increments != 1 {
t.Fatalf("in-place increments = %d, want 1", store.increments)
}
if store.loads != 0 || store.saves != 0 {
t.Fatalf("increment path must not rewrite the whole ledger: loads=%d saves=%d",
store.loads, store.saves)
}
// 同一账号连续两次自增必须累加,而不是覆盖。
if balance, err := AddFragment(context.Background(), uow, "account-1", "7", 101020256, 2, testTime()); err != nil || balance != 5 {
t.Fatalf("second AddFragment() balance=%d error=%v", balance, err)
}
if store.increments != 2 {
t.Fatalf("in-place increments = %d, want 2", store.increments)
}
}
// 不支持增量的后端(内存、SQLite)必须继续可用,且语义与增量路径一致。
// 这条守住"修 A 不能坏 B":新增的增量路径不能把别的后端变成不可用。
func TestAddFragmentFallsBackToWholeLedgerRewrite(t *testing.T) {
store := &rewriteOnlyStore{balances: map[uint32]uint32{101020256: 4}}
uow := &stubEpicBookCraftUnitOfWork{store: store}
balance, err := AddFragment(context.Background(), uow, "account-1", "7", 101020256, 3, testTime())
if err != nil || balance != 7 {
t.Fatalf("AddFragment() balance=%d error=%v", balance, err)
}
if store.saves != 1 {
t.Fatalf("fallback saves = %d, want 1", store.saves)
}
if store.balances[101020256] != 7 {
t.Fatalf("fallback balance = %d, want 7", store.balances[101020256])
}
if store.updatedAt != testTime() {
t.Fatalf("fallback UpdatedAt = %v, want %v", store.updatedAt, testTime())
}
// 整本重写路径的余额上限判定必须原样保留,且不得落库。
store.balances[101020256] = math.MaxUint32
savesBefore := store.saves
if _, err := AddFragment(context.Background(), uow, "account-1", "7", 101020256, 1, testTime()); !errors.Is(err, ErrBalanceOverflow) {
t.Fatalf("overflow error = %v, want ErrBalanceOverflow", err)
}
if store.saves != savesBefore {
t.Fatalf("overflow must not persist: saves=%d want=%d", store.saves, savesBefore)
}
if store.balances[101020256] != math.MaxUint32 {
t.Fatalf("overflow mutated balance to %d", store.balances[101020256])
}
}
// 参数校验仍然发生在进入事务之前,保持原错误值不变。
func TestAddFragmentKeepsPreTransactionValidation(t *testing.T) {
store := &recordingIncrementStore{balances: map[uint32]uint32{}}
uow := &stubEpicBookCraftUnitOfWork{store: store}
cases := []struct {
name string
uow dnfrepo.EpicBookCraftUnitOfWork
accountID string
characterID string
equipmentID uint32
amount uint32
}{
{"nil unit of work", nil, "account-1", "7", 101020256, 1},
{"blank account", uow, " ", "7", 101020256, 1},
{"blank character", uow, "account-1", " ", 101020256, 1},
{"zero equipment", uow, "account-1", "7", 0, 1},
{"zero amount", uow, "account-1", "7", 101020256, 0},
}
for _, tc := range cases {
if _, err := AddFragment(context.Background(), tc.uow, tc.accountID, tc.characterID, tc.equipmentID, tc.amount, testTime()); !errors.Is(err, ErrLedgerUnavailable) {
t.Fatalf("%s: AddFragment() error = %v, want ErrLedgerUnavailable", tc.name, err)
}
}
if store.increments != 0 {
t.Fatalf("invalid calls reached the store: increments=%d", store.increments)
}
}
type stubEpicBookCraftUnitOfWork struct {
store dnfrepo.EpicBookRepository
}
func (u *stubEpicBookCraftUnitOfWork) WithinEpicBookCraft(
_ context.Context,
_, _ string,
apply func(dnfrepo.EpicBookRepository, dnfrepo.AccountInventoryRepository, dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
) error {
return apply(u.store, nil, nil, nil)
}
// recordingIncrementStore 实现 EpicBookBalanceIncrementer,用来证明调用方确实
// 走了增量分支、且完全绕开了整本账的 Load/Save。
type recordingIncrementStore struct {
balances map[uint32]uint32
increments int
loads int
saves int
}
func (s *recordingIncrementStore) Load(context.Context, string) (dnfrepo.EpicBookRecord, bool, error) {
s.loads++
return dnfrepo.EpicBookRecord{AccountID: "account-1", Balances: s.balances}, true, nil
}
func (s *recordingIncrementStore) Save(context.Context, dnfrepo.EpicBookRecord) error {
s.saves++
return nil
}
func (s *recordingIncrementStore) AddBalance(_ context.Context, _ string, equipmentID uint32, amount uint32, _ time.Time) (uint32, error) {
s.increments++
s.balances[equipmentID] += amount
return s.balances[equipmentID], nil
}
// rewriteOnlyStore 故意不实现 EpicBookBalanceIncrementer,代表内存/SQLite 后端。
type rewriteOnlyStore struct {
balances map[uint32]uint32
saves int
updatedAt time.Time
}
func (s *rewriteOnlyStore) Load(context.Context, string) (dnfrepo.EpicBookRecord, bool, error) {
cloned := make(map[uint32]uint32, len(s.balances))
for equipmentID, balance := range s.balances {
cloned[equipmentID] = balance
}
return dnfrepo.EpicBookRecord{AccountID: "account-1", Balances: cloned}, true, nil
}
func (s *rewriteOnlyStore) Save(_ context.Context, record dnfrepo.EpicBookRecord) error {
s.saves++
s.balances = record.Balances
s.updatedAt = record.UpdatedAt
return nil
}