DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,298 @@
|
||||
package epicbook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const PieceInfoPath = "etc/epicpieceinfo.etc"
|
||||
|
||||
var (
|
||||
ErrSourceRequired = errors.New("dnf epic book PVF source is required")
|
||||
ErrCatalogInvalid = errors.New("dnf epic book catalog is invalid")
|
||||
)
|
||||
|
||||
type TextSource interface {
|
||||
ReadText(string) (string, error)
|
||||
}
|
||||
|
||||
type Material struct {
|
||||
ItemID uint32
|
||||
Count uint32
|
||||
}
|
||||
|
||||
type Recipe struct {
|
||||
OutputItemID uint32
|
||||
FragmentID uint32
|
||||
FragmentCount uint32
|
||||
Materials []Material
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
EquipmentID uint32
|
||||
FragmentID uint32
|
||||
Recipe Recipe
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
byEquipment map[uint32]Entry
|
||||
byFragment map[uint32]Entry
|
||||
}
|
||||
|
||||
func Parse(text string) (*Catalog, error) {
|
||||
document, err := dnfpvf.Parse(PieceInfoPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: parse %s: %v", ErrCatalogInvalid, PieceInfoPath, err)
|
||||
}
|
||||
return ParseDocument(document)
|
||||
}
|
||||
|
||||
func ParseDocument(document *dnfpvf.Document) (*Catalog, error) {
|
||||
if document == nil {
|
||||
return nil, fmt.Errorf("%w: document is nil", ErrCatalogInvalid)
|
||||
}
|
||||
mapping, err := parsePieceMapping(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipes, err := parseRecipes(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mapping) == 0 || len(recipes) == 0 || len(mapping) != len(recipes) {
|
||||
return nil, fmt.Errorf("%w: mappings=%d recipes=%d", ErrCatalogInvalid, len(mapping), len(recipes))
|
||||
}
|
||||
|
||||
catalog := &Catalog{
|
||||
byEquipment: make(map[uint32]Entry, len(mapping)),
|
||||
byFragment: make(map[uint32]Entry, len(mapping)),
|
||||
}
|
||||
for equipmentID, fragmentID := range mapping {
|
||||
recipe, ok := recipes[equipmentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: equipment=%d has no recipe", ErrCatalogInvalid, equipmentID)
|
||||
}
|
||||
if recipe.FragmentID != fragmentID {
|
||||
return nil, fmt.Errorf("%w: equipment=%d fragment=%d recipe_fragment=%d", ErrCatalogInvalid, equipmentID, fragmentID, recipe.FragmentID)
|
||||
}
|
||||
entry := Entry{EquipmentID: equipmentID, FragmentID: fragmentID, Recipe: recipe}
|
||||
catalog.byEquipment[equipmentID] = entry
|
||||
if previous, exists := catalog.byFragment[fragmentID]; exists {
|
||||
return nil, fmt.Errorf("%w: fragment=%d maps to equipment=%d and equipment=%d", ErrCatalogInvalid, fragmentID, previous.EquipmentID, equipmentID)
|
||||
}
|
||||
catalog.byFragment[fragmentID] = entry
|
||||
}
|
||||
for equipmentID := range recipes {
|
||||
if _, ok := mapping[equipmentID]; !ok {
|
||||
return nil, fmt.Errorf("%w: recipe equipment=%d has no piece mapping", ErrCatalogInvalid, equipmentID)
|
||||
}
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) EntryByEquipment(equipmentID uint32) (Entry, bool) {
|
||||
if c == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
entry, ok := c.byEquipment[equipmentID]
|
||||
return cloneEntry(entry), ok
|
||||
}
|
||||
|
||||
func (c *Catalog) EntryByFragment(fragmentID uint32) (Entry, bool) {
|
||||
if c == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
entry, ok := c.byFragment[fragmentID]
|
||||
return cloneEntry(entry), ok
|
||||
}
|
||||
|
||||
func (c *Catalog) Entries() []Entry {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint32, 0, len(c.byEquipment))
|
||||
for equipmentID := range c.byEquipment {
|
||||
ids = append(ids, equipmentID)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
entries := make([]Entry, 0, len(ids))
|
||||
for _, equipmentID := range ids {
|
||||
entries = append(entries, cloneEntry(c.byEquipment[equipmentID]))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (c *Catalog) Len() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return len(c.byEquipment)
|
||||
}
|
||||
|
||||
func parsePieceMapping(document *dnfpvf.Document) (map[uint32]uint32, error) {
|
||||
tokens, ok := uniqueSectionTokens(document, "piece list")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: missing [piece list]", ErrCatalogInvalid)
|
||||
}
|
||||
values, err := positiveUintTokens(tokens, "piece list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(values) == 0 || len(values)%2 != 0 {
|
||||
return nil, fmt.Errorf("%w: [piece list] value_count=%d", ErrCatalogInvalid, len(values))
|
||||
}
|
||||
mapping := make(map[uint32]uint32, len(values)/2)
|
||||
fragments := make(map[uint32]uint32, len(values)/2)
|
||||
for index := 0; index < len(values); index += 2 {
|
||||
equipmentID, fragmentID := values[index], values[index+1]
|
||||
if previous, exists := mapping[equipmentID]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate equipment=%d fragments=%d,%d", ErrCatalogInvalid, equipmentID, previous, fragmentID)
|
||||
}
|
||||
if previous, exists := fragments[fragmentID]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate fragment=%d equipment=%d,%d", ErrCatalogInvalid, fragmentID, previous, equipmentID)
|
||||
}
|
||||
mapping[equipmentID] = fragmentID
|
||||
fragments[fragmentID] = equipmentID
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
func parseRecipes(document *dnfpvf.Document) (map[uint32]Recipe, error) {
|
||||
recipes := make(map[uint32]Recipe)
|
||||
inRecipeInfo := false
|
||||
inInfo := false
|
||||
var outputID uint32
|
||||
var recipeValues []uint32
|
||||
|
||||
commit := func() error {
|
||||
if outputID == 0 || len(recipeValues) < 2 || len(recipeValues)%2 != 0 {
|
||||
return fmt.Errorf("%w: recipe output=%d value_count=%d", ErrCatalogInvalid, outputID, len(recipeValues))
|
||||
}
|
||||
if _, exists := recipes[outputID]; exists {
|
||||
return fmt.Errorf("%w: duplicate recipe output=%d", ErrCatalogInvalid, outputID)
|
||||
}
|
||||
recipe := Recipe{
|
||||
OutputItemID: outputID,
|
||||
FragmentID: recipeValues[0],
|
||||
FragmentCount: recipeValues[1],
|
||||
}
|
||||
for index := 2; index < len(recipeValues); index += 2 {
|
||||
recipe.Materials = append(recipe.Materials, Material{ItemID: recipeValues[index], Count: recipeValues[index+1]})
|
||||
}
|
||||
recipes[outputID] = recipe
|
||||
return nil
|
||||
}
|
||||
|
||||
for index, token := range document.Tokens {
|
||||
if token.Kind != dnfpvf.TokenSection {
|
||||
continue
|
||||
}
|
||||
name := normalizeSection(token.Value)
|
||||
switch name {
|
||||
case "equipment recipe info":
|
||||
inRecipeInfo = true
|
||||
case "/equipment recipe info":
|
||||
if inInfo {
|
||||
return nil, fmt.Errorf("%w: unterminated [info]", ErrCatalogInvalid)
|
||||
}
|
||||
inRecipeInfo = false
|
||||
case "info":
|
||||
if !inRecipeInfo || inInfo {
|
||||
return nil, fmt.Errorf("%w: unexpected [info]", ErrCatalogInvalid)
|
||||
}
|
||||
inInfo = true
|
||||
outputID = 0
|
||||
recipeValues = nil
|
||||
case "/info":
|
||||
if !inRecipeInfo || !inInfo {
|
||||
return nil, fmt.Errorf("%w: unexpected [/info]", ErrCatalogInvalid)
|
||||
}
|
||||
if err := commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inInfo = false
|
||||
case "output":
|
||||
if !inRecipeInfo || !inInfo {
|
||||
continue
|
||||
}
|
||||
values, err := positiveUintTokens(sectionTokensAt(document, index), "output")
|
||||
if err != nil || len(values) != 1 {
|
||||
return nil, fmt.Errorf("%w: [output] value_count=%d", ErrCatalogInvalid, len(values))
|
||||
}
|
||||
outputID = values[0]
|
||||
case "recipe":
|
||||
if !inRecipeInfo || !inInfo {
|
||||
continue
|
||||
}
|
||||
values, err := positiveUintTokens(sectionTokensAt(document, index), "recipe")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipeValues = values
|
||||
}
|
||||
}
|
||||
if inInfo || inRecipeInfo {
|
||||
return nil, fmt.Errorf("%w: unterminated recipe section", ErrCatalogInvalid)
|
||||
}
|
||||
return recipes, nil
|
||||
}
|
||||
|
||||
func uniqueSectionTokens(document *dnfpvf.Document, name string) ([]dnfpvf.Token, bool) {
|
||||
key := normalizeSection(name)
|
||||
var result []dnfpvf.Token
|
||||
count := 0
|
||||
for _, section := range document.Sections {
|
||||
if normalizeSection(section.Name) != key {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if section.Start < 0 || section.End > len(document.Tokens) || section.Start > section.End {
|
||||
return nil, false
|
||||
}
|
||||
result = document.Tokens[section.Start:section.End]
|
||||
}
|
||||
if count != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func sectionTokensAt(document *dnfpvf.Document, tokenIndex int) []dnfpvf.Token {
|
||||
start := tokenIndex + 1
|
||||
end := len(document.Tokens)
|
||||
for index := start; index < len(document.Tokens); index++ {
|
||||
if document.Tokens[index].Kind == dnfpvf.TokenSection {
|
||||
end = index
|
||||
break
|
||||
}
|
||||
}
|
||||
return document.Tokens[start:end]
|
||||
}
|
||||
|
||||
func positiveUintTokens(tokens []dnfpvf.Token, section string) ([]uint32, error) {
|
||||
values := make([]uint32, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if token.Kind == dnfpvf.TokenSection {
|
||||
break
|
||||
}
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int <= 0 || token.Int > math.MaxUint32 {
|
||||
return nil, fmt.Errorf("%w: [%s] token=%q is not a positive u32", ErrCatalogInvalid, section, token.Raw)
|
||||
}
|
||||
values = append(values, uint32(token.Int))
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func normalizeSection(value string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
||||
}
|
||||
|
||||
func cloneEntry(entry Entry) Entry {
|
||||
entry.Recipe.Materials = append([]Material(nil), entry.Recipe.Materials...)
|
||||
return entry
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package epicbook
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCatalog(t *testing.T) {
|
||||
catalog, err := Parse(`[equipment piece drop info]
|
||||
[piece list]
|
||||
101020256 10300528 101020258 10300529
|
||||
[/piece list]
|
||||
[/equipment piece drop info]
|
||||
[equipment recipe info]
|
||||
[info]
|
||||
[output]
|
||||
101020256
|
||||
[recipe]
|
||||
10300528 1000 10099775 44 10158124 6
|
||||
[/recipe]
|
||||
[/info]
|
||||
[info]
|
||||
[output]
|
||||
101020258
|
||||
[recipe]
|
||||
10300529 1000 10099775 49 10158124 8
|
||||
[/recipe]
|
||||
[/info]
|
||||
[/equipment recipe info]`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if catalog.Len() != 2 {
|
||||
t.Fatalf("Len() = %d, want 2", catalog.Len())
|
||||
}
|
||||
entry, ok := catalog.EntryByEquipment(101020256)
|
||||
if !ok || entry.FragmentID != 10300528 || entry.Recipe.FragmentCount != 1000 || len(entry.Recipe.Materials) != 2 {
|
||||
t.Fatalf("EntryByEquipment() = %+v, %v", entry, ok)
|
||||
}
|
||||
if entry.Recipe.Materials[0] != (Material{ItemID: 10099775, Count: 44}) {
|
||||
t.Fatalf("first material = %+v", entry.Recipe.Materials[0])
|
||||
}
|
||||
reverse, ok := catalog.EntryByFragment(10300529)
|
||||
if !ok || reverse.EquipmentID != 101020258 {
|
||||
t.Fatalf("EntryByFragment() = %+v, %v", reverse, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalogRejectsFragmentMismatch(t *testing.T) {
|
||||
_, err := Parse(`[equipment piece drop info]
|
||||
[piece list]
|
||||
101020256 10300528
|
||||
[/piece list]
|
||||
[/equipment piece drop info]
|
||||
[equipment recipe info]
|
||||
[info]
|
||||
[output]
|
||||
101020256
|
||||
[recipe]
|
||||
10300529 1000
|
||||
[/recipe]
|
||||
[/info]
|
||||
[/equipment recipe info]`)
|
||||
if !errors.Is(err, ErrCatalogInvalid) {
|
||||
t.Fatalf("Parse() error = %v, want ErrCatalogInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalogRejectsDuplicateFragment(t *testing.T) {
|
||||
_, err := Parse(`[equipment piece drop info]
|
||||
[piece list]
|
||||
1 10 2 10
|
||||
[/piece list]
|
||||
[/equipment piece drop info]
|
||||
[equipment recipe info]
|
||||
[/equipment recipe info]`)
|
||||
if !errors.Is(err, ErrCatalogInvalid) {
|
||||
t.Fatalf("Parse() error = %v, want ErrCatalogInvalid", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package epicbook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLedgerUnavailable = errors.New("dnf epic book ledger is unavailable")
|
||||
// ErrBalanceOverflow 表示余额已到 uint32 上限、本次增加被整体拒绝。
|
||||
//
|
||||
// 余额上限的实际判定,以及"单行增量"与"整本账重写"两条路径的分流,都在
|
||||
// repository 层(repository.AddEpicBookBalance)。这里保留包内旧名字作为
|
||||
// 别名:它与 repository.ErrBalanceOverflow 是同一个值,调用方与该值的
|
||||
// errors.Is 比较行为不变。
|
||||
ErrBalanceOverflow = dnfrepo.ErrBalanceOverflow
|
||||
)
|
||||
|
||||
// AddFragment 给账号的某个史诗碎片余额加量,并返回写入后的余额。
|
||||
//
|
||||
// 落库细节(走单行 upsert 还是整本账重写)由 repository 决定:MySQL 后端用
|
||||
// 单行 upsert,避免同一账号并发拾取时在 REPEATABLE READ 的间隙锁上形成死锁;
|
||||
// 内存/SQLite 后端没有该锁环,继续走整本账重写。两条路径结果语义一致。
|
||||
func AddFragment(
|
||||
ctx context.Context,
|
||||
uow dnfrepo.EpicBookCraftUnitOfWork,
|
||||
accountID string,
|
||||
characterID string,
|
||||
equipmentID uint32,
|
||||
amount uint32,
|
||||
now time.Time,
|
||||
) (uint32, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if uow == nil || accountID == "" || characterID == "" || equipmentID == 0 || amount == 0 {
|
||||
return 0, ErrLedgerUnavailable
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
var balance uint32
|
||||
err := uow.WithinEpicBookCraft(ctx, accountID, characterID, func(epicBooks dnfrepo.EpicBookRepository, _ dnfrepo.AccountInventoryRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||||
var addErr error
|
||||
balance, addErr = dnfrepo.AddEpicBookBalance(ctx, epicBooks, accountID, equipmentID, amount, now)
|
||||
return addErr
|
||||
})
|
||||
// 与原实现一致:出错时也把回调里已经算出的余额一并返回,调用方按 error
|
||||
// 判定成败,不会读这个值。
|
||||
return balance, err
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package epicbook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestAddFragmentPersistsAccountBalance(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "7"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
balance, err := AddFragment(context.Background(), repositories.EpicBookCraft, "account-1", "7", 101020256, 3, testTime())
|
||||
if err != nil || balance != 3 {
|
||||
t.Fatalf("AddFragment() balance=%d error=%v", balance, err)
|
||||
}
|
||||
balance, err = AddFragment(context.Background(), repositories.EpicBookCraft, "account-1", "7", 101020256, 2, testTime())
|
||||
if err != nil || balance != 5 {
|
||||
t.Fatalf("AddFragment() second balance=%d error=%v", balance, err)
|
||||
}
|
||||
record, found, err := repositories.EpicBook.Load(context.Background(), "account-1")
|
||||
if err != nil || !found || record.Balances[101020256] != 5 {
|
||||
t.Fatalf("stored record=%+v found=%t error=%v", record, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFragmentRejectsOverflowWithoutMutation(t *testing.T) {
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{CharacterID: "7"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.EpicBook.Save(context.Background(), dnfrepo.EpicBookRecord{
|
||||
AccountID: "account-1",
|
||||
Balances: map[uint32]uint32{101020256: math.MaxUint32},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AddFragment(context.Background(), repositories.EpicBookCraft, "account-1", "7", 101020256, 1, testTime()); !errors.Is(err, ErrBalanceOverflow) {
|
||||
t.Fatalf("AddFragment() error=%v, want ErrBalanceOverflow", err)
|
||||
}
|
||||
record, _, _ := repositories.EpicBook.Load(context.Background(), "account-1")
|
||||
if record.Balances[101020256] != math.MaxUint32 {
|
||||
t.Fatalf("balance mutated to %d", record.Balances[101020256])
|
||||
}
|
||||
}
|
||||
|
||||
func testTime() (value time.Time) {
|
||||
return time.Unix(1_700_000_000, 0).UTC()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package epicbook
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestRealPVFCatalog(t *testing.T) {
|
||||
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if path == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the runtime epic book catalog")
|
||||
}
|
||||
archive, err := platformpvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%q) error = %v", path, err)
|
||||
}
|
||||
text, err := archive.ReadText(PieceInfoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadText(%q) error = %v", PieceInfoPath, err)
|
||||
}
|
||||
catalog, err := Parse(text)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(real PVF) error = %v", err)
|
||||
}
|
||||
if catalog.Len() < 100 {
|
||||
t.Fatalf("real catalog entries = %d, want at least 100", catalog.Len())
|
||||
}
|
||||
entry, ok := catalog.EntryByEquipment(101020256)
|
||||
if !ok || entry.FragmentID != 10300528 || entry.Recipe.FragmentCount != 1000 {
|
||||
t.Fatalf("known entry = %+v, %v", entry, ok)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user