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,240 @@
package memory
import (
"context"
"database/sql"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
"strings"
"testing"
platformdb "longheng.io/server/internal/platform/db"
)
func TestMemoryAccountInventoryIsSharedWhileOrdinarySlotsRemainCharacterScoped(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
shared := repository.ItemStack{
ItemID: 3033,
Count: 25,
Bind: true,
RawEntry: []byte{1, 2, 3, 4},
Extra: map[string]string{"kind": "cube"},
}
if err := repos.AccountInventory.Save(ctx, repository.AccountInventoryRecord{
AccountID: "dnf:1",
Slots: map[string]repository.ItemStack{repository.AccountSharedInventorySlotKey(354): shared},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{"0:9": {ItemID: 700, Count: 1}}}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "78", Slots: map[string]repository.ItemStack{"0:9": {ItemID: 800, Count: 1}}}); err != nil {
t.Fatal(err)
}
account, _, _ := repos.AccountInventory.Load(ctx, "dnf:1")
first, _, _ := repos.Inventory.Load(ctx, "77")
second, _, _ := repos.Inventory.Load(ctx, "78")
first = repository.MergeAccountSharedInventory(first, account)
second = repository.MergeAccountSharedInventory(second, account)
if got := first.Slots["0:354"]; got.ItemID != 3033 || got.Count != 25 || !got.Bind || string(got.RawEntry) != string(shared.RawEntry) || got.Extra["kind"] != "cube" {
t.Fatalf("first shared slot = %+v", got)
}
if got := second.Slots["0:354"]; got.ItemID != 3033 || got.Count != 25 {
t.Fatalf("second shared slot = %+v", got)
}
if first.Slots["0:9"].ItemID != 700 || second.Slots["0:9"].ItemID != 800 {
t.Fatalf("ordinary slots leaked first=%+v second=%+v", first.Slots, second.Slots)
}
mutated := first.Slots["0:354"]
mutated.RawEntry = []byte{9}
mutated.Extra = map[string]string{"kind": "mutated"}
first.Slots["0:354"] = mutated
reloaded, _, _ := repos.AccountInventory.Load(ctx, "dnf:1")
if got := reloaded.Slots["0:354"]; got.ItemID != 3033 || string(got.RawEntry) != string(shared.RawEntry) || got.Extra["kind"] != "cube" {
t.Fatalf("merged view aliased account record: %+v", got)
}
}
func TestMemoryAccountCharacterItemUnitOfWorkCommitsBothOwners(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedAccountCharacterInventory(t, ctx, repos)
err := repos.AccountItems.WithinAccountCharacterItems(ctx, "dnf:1", "77", func(accounts repository.AccountInventoryRepository, characters repository.InventoryRepository) error {
account, _, err := accounts.Load(ctx, "dnf:1")
if err != nil {
return err
}
character, _, err := characters.Load(ctx, "77")
if err != nil {
return err
}
stack := character.Slots["0:9"]
delete(character.Slots, "0:9")
account.Slots["0:354"] = stack
if err := characters.Save(ctx, character); err != nil {
return err
}
return accounts.Save(ctx, account)
})
if err != nil {
t.Fatal(err)
}
account, _, _ := repos.AccountInventory.Load(ctx, "dnf:1")
character, _, _ := repos.Inventory.Load(ctx, "77")
if account.Slots["0:354"].ItemID != 700 || account.Slots["0:354"].Extra["origin"] != "character" {
t.Fatalf("account inventory = %+v", account.Slots)
}
if _, ok := character.Slots["0:9"]; ok {
t.Fatalf("character source was not removed: %+v", character.Slots)
}
}
func TestMemoryAccountCharacterItemUnitOfWorkRollsBackBothOwners(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedAccountCharacterInventory(t, ctx, repos)
wantErr := errors.New("reject both item owners")
err := repos.AccountItems.WithinAccountCharacterItems(ctx, "dnf:1", "77", func(accounts repository.AccountInventoryRepository, characters repository.InventoryRepository) error {
account, _, _ := accounts.Load(ctx, "dnf:1")
character, _, _ := characters.Load(ctx, "77")
delete(account.Slots, "0:354")
delete(character.Slots, "0:9")
if err := accounts.Save(ctx, account); err != nil {
return err
}
if err := characters.Save(ctx, character); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
assertAccountCharacterInventorySeed(t, ctx, repos)
}
func TestMemoryAccountCharacterItemUnitOfWorkRejectsCrossOwnerAccess(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedAccountCharacterInventory(t, ctx, repos)
err := repos.AccountItems.WithinAccountCharacterItems(ctx, "dnf:1", "77", func(accounts repository.AccountInventoryRepository, characters repository.InventoryRepository) error {
if _, _, err := accounts.Load(ctx, "dnf:2"); err == nil {
return errors.New("cross-account read unexpectedly succeeded")
} else if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
return err
}
return characters.Save(ctx, repository.InventoryRecord{CharacterID: "78"})
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("error = %v, want ErrRecordKeyRequired", err)
}
assertAccountCharacterInventorySeed(t, ctx, repos)
}
func TestMySQLAccountCharacterItemUnitOfWorkCommitsTwoOwners(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatal(err)
}
err = repos.AccountItems.WithinAccountCharacterItems(context.Background(), "dnf:1", "77", func(accounts repository.AccountInventoryRepository, characters repository.InventoryRepository) error {
if err := accounts.Save(context.Background(), repository.AccountInventoryRecord{AccountID: "dnf:1", Slots: map[string]repository.ItemStack{"0:354": {ItemID: 3033, Count: 1}}}); err != nil {
return err
}
return characters.Save(context.Background(), repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{"0:9": {ItemID: 700, Count: 1}}})
})
if err != nil {
t.Fatal(err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 1 || rollback != 0 {
t.Fatalf("begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
joined := strings.Join(queries, "\n")
if !strings.Contains(joined, "`dnf_s1_w1`.`dnf_account_inventories`") ||
!strings.Contains(joined, "`dnf_s1_w1`.`dnf_account_inventory_items`") ||
!strings.Contains(joined, "`dnf_s1_w1`.`dnf_inventories`") ||
!strings.Contains(joined, "`dnf_s1_w1`.`dnf_inventory_items`") {
t.Fatalf("queries = %v", queries)
}
}
func TestMySQLAccountCharacterItemUnitOfWorkRollsBackExecutedWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatal(err)
}
wantErr := errors.New("rollback account and character items")
err = repos.AccountItems.WithinAccountCharacterItems(context.Background(), "dnf:1", "77", func(accounts repository.AccountInventoryRepository, characters repository.InventoryRepository) error {
if err := accounts.Save(context.Background(), repository.AccountInventoryRecord{AccountID: "dnf:1"}); err != nil {
return err
}
if err := characters.Save(context.Background(), repository.InventoryRecord{CharacterID: "77"}); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 {
t.Fatalf("begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
}
func TestMySQLSchemaIncludesAccountInventoryTable(t *testing.T) {
schema, err := mysql.MySQLSchema(mysql.SchemaOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(schema, "\n")
if !strings.Contains(joined, "CREATE TABLE IF NOT EXISTS `dnf_s1_w1`.`dnf_account_inventories`") ||
!strings.Contains(joined, "CREATE TABLE IF NOT EXISTS `dnf_s1_w1`.`dnf_account_inventory_items`") ||
strings.Contains(joined, "slots_json JSON NULL") ||
!strings.Contains(joined, "PRIMARY KEY (account_id)") {
t.Fatalf("account inventory schema missing:\n%s", joined)
}
}
func seedAccountCharacterInventory(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if err := repos.AccountInventory.Save(ctx, repository.AccountInventoryRecord{
AccountID: "dnf:1",
Slots: map[string]repository.ItemStack{"0:354": {ItemID: 3033, Count: 5, RawEntry: []byte{3, 5, 4}, Extra: map[string]string{"origin": "account"}}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{
CharacterID: "77",
Slots: map[string]repository.ItemStack{"0:9": {ItemID: 700, Count: 1, RawEntry: []byte{7, 0, 0}, Extra: map[string]string{"origin": "character"}}},
}); err != nil {
t.Fatal(err)
}
}
func assertAccountCharacterInventorySeed(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
account, _, _ := repos.AccountInventory.Load(ctx, "dnf:1")
character, _, _ := repos.Inventory.Load(ctx, "77")
if got := account.Slots["0:354"]; got.ItemID != 3033 || got.Count != 5 || got.Extra["origin"] != "account" {
t.Fatalf("account rollback failed: %+v", got)
}
if got := character.Slots["0:9"]; got.ItemID != 700 || got.Count != 1 || got.Extra["origin"] != "character" {
t.Fatalf("character rollback failed: %+v", got)
}
}
@@ -0,0 +1,161 @@
package memory
import (
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/repository"
)
// 商城/冒险团商店这几条路会卖出账号共享道具(无色小晶块、灵魂,落 list 0 的
// 354..365 固定格,由**账号记录**持有),所以账号仓必须和扣点在同一个事务里:
//
// · 事务里交出来的账号仓必须真的能读写;
// · 提交后落库;
// · 回调失败时整批(含账号仓)一起回滚 —— 绝不能扣了点却没发货,或反过来。
func TestCeraShopAssetTransactionCarriesAccountInventory(t *testing.T) {
ctx := context.Background()
repositories := NewMemoryGroup()
if err := repositories.Account.Save(ctx, repository.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "1000"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "22"}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, repository.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]repository.ItemStack{"0:365": {ItemID: 10158124, Count: 3}},
}); err != nil {
t.Fatal(err)
}
committed := 0
err := repositories.CeraShopAssets.WithinCeraShopAssets(
ctx, "account-1", "22", repository.CharacterContainerStateScope("22"),
func(
accounts repository.AccountRepository,
accountInventories repository.AccountInventoryRepository,
_ repository.CharacterRepository,
_ repository.InventoryRepository,
_ repository.EquipmentRepository,
_ repository.SettingsRepository,
) error {
account, found, err := accounts.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
account.Metadata["account_cera"] = "900"
if err := accounts.Save(ctx, account); err != nil {
return err
}
inventory, found, err := accountInventories.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
inventory.Slots["0:358"] = repository.ItemStack{ItemID: 3037, Count: 43890}
if err := accountInventories.Save(ctx, inventory); err != nil {
return err
}
committed++
return nil
},
)
if err != nil {
t.Fatal(err)
}
if committed != 1 {
t.Fatalf("callback ran %d times", committed)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
if account.Metadata["account_cera"] != "900" {
t.Fatalf("account cera was not committed: %+v", account.Metadata)
}
stored, found, err := repositories.AccountInventory.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
if crystal, exists := stored.Slots["0:358"]; !exists || crystal.ItemID != 3037 || crystal.Count != 43890 {
t.Fatalf("account warehouse write was not committed: %+v", stored.Slots)
}
}
func TestCeraShopAssetTransactionRollsBackAccountInventoryOnFailure(t *testing.T) {
ctx := context.Background()
repositories := NewMemoryGroup()
if err := repositories.Account.Save(ctx, repository.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "1000"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "22"}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, repository.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]repository.ItemStack{"0:358": {ItemID: 3037, Count: 43890}},
}); err != nil {
t.Fatal(err)
}
sentinel := errors.New("checkout failed after the credit")
err := repositories.CeraShopAssets.WithinCeraShopAssets(
ctx, "account-1", "22", repository.CharacterContainerStateScope("22"),
func(
accounts repository.AccountRepository,
accountInventories repository.AccountInventoryRepository,
_ repository.CharacterRepository,
_ repository.InventoryRepository,
_ repository.EquipmentRepository,
_ repository.SettingsRepository,
) error {
account, _, err := accounts.Load(ctx, "account-1")
if err != nil {
return err
}
account.Metadata["account_cera"] = "0"
if err := accounts.Save(ctx, account); err != nil {
return err
}
inventory, _, err := accountInventories.Load(ctx, "account-1")
if err != nil {
return err
}
inventory.Slots["0:358"] = repository.ItemStack{ItemID: 3037, Count: 43910}
if err := accountInventories.Save(ctx, inventory); err != nil {
return err
}
return sentinel
},
)
if !errors.Is(err, sentinel) {
t.Fatalf("transaction error=%v want the callback error", err)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
if account.Metadata["account_cera"] != "1000" {
t.Fatalf("failed checkout kept the debit: %+v", account.Metadata)
}
stored, _, err := repositories.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if crystal := stored.Slots["0:358"]; crystal.Count != 43890 {
t.Fatalf("failed checkout kept the account warehouse grant: %+v", crystal)
}
}
@@ -0,0 +1,65 @@
package memory
import (
"context"
"longheng.io/server/internal/modules/dnf/repository"
"testing"
)
func TestCeraShopAssetTransactionRejectsCrossSettingsScope(t *testing.T) {
ctx := context.Background()
repositories := NewMemoryGroup()
if err := repositories.Account.Save(ctx, repository.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "1000"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "22"}); err != nil {
t.Fatal(err)
}
err := repositories.CeraShopAssets.WithinCeraShopAssets(
ctx,
"account-1",
"22",
repository.CharacterContainerStateScope("22"),
func(
accounts repository.AccountRepository,
_ repository.AccountInventoryRepository,
_ repository.CharacterRepository,
_ repository.InventoryRepository,
_ repository.EquipmentRepository,
settings repository.SettingsRepository,
) error {
account, _, err := accounts.Load(ctx, "account-1")
if err != nil {
return err
}
account.Metadata["account_cera"] = "900"
if err := accounts.Save(ctx, account); err != nil {
return err
}
return settings.Save(ctx, repository.SettingsRecord{
Scope: repository.CharacterContainerStateScope("23"),
Values: map[string]string{"main_list_param16": "8"},
})
},
)
if err == nil {
t.Fatal("cross-character settings mutation was accepted")
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found || account.Metadata["account_cera"] != "1000" {
t.Fatalf("account=%+v found=%t err=%v", account, found, err)
}
if _, found, err := repositories.Settings.Load(ctx, repository.CharacterContainerStateScope("23")); err != nil || found {
t.Fatalf("forged settings found=%t err=%v", found, err)
}
}
@@ -0,0 +1,176 @@
// 本文件验证 DNF 角色创建仓储必须使用 insert-only 语义,避免新建角色覆盖旧槽位。
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"strconv"
"testing"
)
type saveOnlyCharacterRepo struct {
saved bool
}
func (s *saveOnlyCharacterRepo) Load(context.Context, string) (repository.CharacterRecord, bool, error) {
return repository.CharacterRecord{}, false, nil
}
func (s *saveOnlyCharacterRepo) Save(context.Context, repository.CharacterRecord) error {
s.saved = true
return nil
}
func (s *saveOnlyCharacterRepo) ListByAccount(context.Context, string, int) ([]repository.CharacterRecord, error) {
return nil, nil
}
func (s *saveOnlyCharacterRepo) FindIDByName(context.Context, string) (string, bool, error) {
return "", false, nil
}
func (s *saveOnlyCharacterRepo) NextNumericID(context.Context) (int, error) {
return 1, nil
}
func TestCreateCharacterRequiresInsertOnlyRepository(t *testing.T) {
repo := &saveOnlyCharacterRepo{}
err := repository.CreateCharacter(context.Background(), repo, repository.CharacterRecord{
CharacterID: "1",
AccountID: "dnf:1",
Slot: 0,
Name: "hero",
})
if !errors.Is(err, repository.ErrCharacterCreateMissing) {
t.Fatalf("CreateCharacter() error = %v, want ErrCharacterCreateMissing", err)
}
if repo.saved {
t.Fatalf("CreateCharacter() fell back to Save and could overwrite an existing slot")
}
}
func TestMemoryCharacterDefaultListAndSlotConflictCoverThirtyTwoSlots(t *testing.T) {
ctx := context.Background()
group := NewMemoryGroup()
for slot := 0; slot < repository.DefaultCharacterSlotLimit; slot++ {
id := strconv.Itoa(slot + 1)
if err := group.Character.Save(ctx, repository.CharacterRecord{
CharacterID: id,
AccountID: "dnf:1",
Slot: slot,
Name: "character-" + id,
}); err != nil {
t.Fatal(err)
}
}
characters, err := group.Character.ListByAccount(ctx, "dnf:1", 0)
if err != nil {
t.Fatal(err)
}
if len(characters) != repository.DefaultCharacterSlotLimit || characters[len(characters)-1].Slot != 31 {
t.Fatalf("default character list count/last slot = %d/%d, want 32/31", len(characters), characters[len(characters)-1].Slot)
}
if err := repository.CreateCharacter(ctx, group.Character, repository.CharacterRecord{
CharacterID: "33",
AccountID: "dnf:1",
Slot: 31,
Name: "duplicate-slot-31",
}); !errors.Is(err, repository.ErrCharacterSlotOccupied) {
t.Fatalf("slot 31 duplicate error = %v, want ErrCharacterSlotOccupied", err)
}
}
func TestMemoryCharacterExpandedSlotsRemainOwnedThroughCreateSwapAndDelete(t *testing.T) {
ctx := context.Background()
group := NewMemoryGroup()
for _, record := range []repository.CharacterRecord{
{CharacterID: "33", AccountID: "dnf:1", Slot: 32, Name: "expanded-left"},
{CharacterID: "34", AccountID: "dnf:1", Slot: 33, Name: "expanded-right"},
} {
if err := repository.CreateCharacter(ctx, group.Character, record); err != nil {
t.Fatal(err)
}
}
if err := repository.CreateCharacter(ctx, group.Character, repository.CharacterRecord{
CharacterID: "35", AccountID: "dnf:1", Slot: 32, Name: "duplicate-expanded-slot",
}); !errors.Is(err, repository.ErrCharacterSlotOccupied) {
t.Fatalf("slot 32 duplicate error=%v, want ErrCharacterSlotOccupied", err)
}
if err := repository.SwapCharacterSlots(ctx, group.Character, "dnf:1", 32, 33); err != nil {
t.Fatal(err)
}
left, _, _ := group.Character.Load(ctx, "33")
right, _, _ := group.Character.Load(ctx, "34")
if left.Slot != 33 || right.Slot != 32 {
t.Fatalf("expanded swap left/right=%d/%d, want 33/32", left.Slot, right.Slot)
}
if err := repository.DeleteCharacterAndCompact(ctx, group.Character, "dnf:1", 32, "expanded-right"); err != nil {
t.Fatal(err)
}
deleted, found, err := group.Character.Load(ctx, "34")
if err != nil || !found || deleted.Slot != -1 || deleted.Stats["delete_flag"] != 1 {
t.Fatalf("expanded deleted record=%+v found=%t err=%v", deleted, found, err)
}
}
func TestMemoryCharacterSwapCharacterSlotsOnlySwapsOccupiedSlots(t *testing.T) {
ctx := context.Background()
group := NewMemoryGroup()
for _, record := range []repository.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Slot: 2, Name: "left"},
{CharacterID: "2", AccountID: "dnf:1", Slot: 7, Name: "right"},
} {
if err := group.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repository.SwapCharacterSlots(ctx, group.Character, "dnf:1", 2, 7); err != nil {
t.Fatalf("swap occupied slots: %v", err)
}
left, _, _ := group.Character.Load(ctx, "1")
right, _, _ := group.Character.Load(ctx, "2")
if left.Slot != 7 || right.Slot != 2 {
t.Fatalf("after swap left/right slots = %d/%d, want 7/2", left.Slot, right.Slot)
}
if err := repository.SwapCharacterSlots(ctx, group.Character, "dnf:1", 7, 31); err != nil {
t.Fatalf("swap with empty slot: %v", err)
}
left, _, _ = group.Character.Load(ctx, "1")
right, _, _ = group.Character.Load(ctx, "2")
if left.Slot != 7 || right.Slot != 2 {
t.Fatalf("empty-slot request moved a character: left/right slots = %d/%d", left.Slot, right.Slot)
}
}
func TestMemoryCharacterDeleteValidatesNameAndCompactsSlots(t *testing.T) {
ctx := context.Background()
group := NewMemoryGroup()
for _, record := range []repository.CharacterRecord{
{CharacterID: "1", AccountID: "dnf:1", Slot: 0, Name: "first"},
{CharacterID: "2", AccountID: "dnf:1", Slot: 1, Name: "delete-me"},
{CharacterID: "3", AccountID: "dnf:1", Slot: 2, Name: "last"},
} {
if err := group.Character.Save(ctx, record); err != nil {
t.Fatal(err)
}
}
if err := repository.DeleteCharacterAndCompact(ctx, group.Character, "dnf:1", 1, "wrong"); !errors.Is(err, repository.ErrCharacterDeleteNameMismatch) {
t.Fatalf("mismatched name error=%v", err)
}
if err := repository.DeleteCharacterAndCompact(ctx, group.Character, "dnf:1", 1, "delete-me"); err != nil {
t.Fatal(err)
}
deleted, found, err := group.Character.Load(ctx, "2")
if err != nil || !found || deleted.Slot != -1 || deleted.Stats["delete_flag"] != 1 {
t.Fatalf("deleted record=%+v found=%t err=%v", deleted, found, err)
}
roster, err := group.Character.ListByAccount(ctx, "dnf:1", 32)
if err != nil {
t.Fatal(err)
}
if len(roster) != 2 || roster[0].Slot != 0 || roster[1].Slot != 1 || roster[1].CharacterID != "3" {
t.Fatalf("compacted roster=%+v", roster)
}
}
@@ -0,0 +1,82 @@
// 本文件由 character_story_digest_test.go 按后端拆分而来。
package memory
import (
"context"
"sync"
"testing"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestMemoryCharacterStoryDigestAdvanceIsMonotonicAndConcurrent(t *testing.T) {
ctx := context.Background()
repo := newMemoryCharStore()
if err := repo.Save(ctx, repository.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:1",
Level: 90,
Stats: map[string]int64{},
}); err != nil {
t.Fatal(err)
}
levels := []uint32{1, 42, 7, 90, 18, 89, 2, 90, 55, 3}
var wg sync.WaitGroup
for _, level := range levels {
level := level
wg.Add(1)
go func() {
defer wg.Done()
if err := repository.AdvanceCharacterStoryDigest(ctx, repo, "77", level, repository.CurrentCharacterStoryDigestMigrationVersion); err != nil {
t.Errorf("advance level %d: %v", level, err)
}
}()
}
wg.Wait()
// A stale retry must not lower either durable scalar.
if err := repository.AdvanceCharacterStoryDigest(ctx, repo, "77", 4, 0); err != nil {
t.Fatal(err)
}
record, found, err := repo.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if got := record.Stats[repository.CharacterStoryDigestLastLevelStatKey]; got != 90 {
t.Fatalf("last story level = %d, want 90", got)
}
if got := record.Stats[repository.CharacterStoryDigestMigrationVersionStatKey]; got != int64(repository.CurrentCharacterStoryDigestMigrationVersion) {
t.Fatalf("migration version = %d, want %d", got, repository.CurrentCharacterStoryDigestMigrationVersion)
}
stale := record
stale.Stats = map[string]int64{"exp": 123}
if err := repo.Save(ctx, stale); err != nil {
t.Fatal(err)
}
record, found, err = repo.Load(ctx, "77")
if err != nil || !found || record.Stats[repository.CharacterStoryDigestLastLevelStatKey] != 90 ||
record.Stats[repository.CharacterStoryDigestMigrationVersionStatKey] != int64(repository.CurrentCharacterStoryDigestMigrationVersion) {
t.Fatalf("stale generic save regressed story state found=%t err=%v stats=%#v", found, err, record.Stats)
}
}
func TestRedisCachedCharacterStoryDigestAdvancesAuthoritativeBacking(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
if err := backing.Save(ctx, repository.CharacterRecord{CharacterID: "88", AccountID: "dnf:1", Level: 60}); err != nil {
t.Fatal(err)
}
repo := repository.NewRedisCachedCharacterRepository(backing, newFakeRedisExecutor(), repository.RedisCacheOptions{KeyPrefix: "story:test"})
if err := repository.AdvanceCharacterStoryDigest(ctx, repo, "88", 60, repository.CurrentCharacterStoryDigestMigrationVersion); err != nil {
t.Fatal(err)
}
loaded, found, err := backing.Load(ctx, "88")
if err != nil || !found {
t.Fatalf("load backing found=%t err=%v", found, err)
}
if loaded.Stats[repository.CharacterStoryDigestLastLevelStatKey] != 60 ||
loaded.Stats[repository.CharacterStoryDigestMigrationVersionStatKey] != int64(repository.CurrentCharacterStoryDigestMigrationVersion) {
t.Fatalf("backing story stats = %#v", loaded.Stats)
}
}
@@ -0,0 +1,41 @@
package memory
import (
"context"
"testing"
)
func TestMemoryDungeonPermissionUpsertMax(t *testing.T) {
store := newMemoryDungeonPermissionStore()
entry, updated, err := store.UpsertMax(context.Background(), "19", 7145, 2)
if err != nil {
t.Fatalf("first UpsertMax error = %v", err)
}
if !updated || entry.DungeonID != 7145 || entry.ClearState != 2 {
t.Fatalf("first entry=%+v updated=%v", entry, updated)
}
entry, updated, err = store.UpsertMax(context.Background(), "19", 7145, 1)
if err != nil {
t.Fatalf("lower UpsertMax error = %v", err)
}
if updated || entry.ClearState != 2 {
t.Fatalf("lower entry=%+v updated=%v, want unchanged state 2", entry, updated)
}
entry, updated, err = store.UpsertMax(context.Background(), "19", 7145, 4)
if err != nil {
t.Fatalf("higher UpsertMax error = %v", err)
}
if !updated || entry.ClearState != 4 {
t.Fatalf("higher entry=%+v updated=%v, want state 4", entry, updated)
}
record, found, err := store.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("Load found=%v err=%v", found, err)
}
if len(record.Entries) != 1 || record.Entries[0].DungeonID != 7145 || record.Entries[0].ClearState != 4 {
t.Fatalf("record=%+v", record)
}
}
@@ -0,0 +1,70 @@
package memory
import (
"context"
"errors"
"reflect"
"testing"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestMemoryEpicBookCraftRollsBackCallbackMutation(t *testing.T) {
repositories := NewMemoryGroup()
ctx := context.Background()
wantBook := repository.EpicBookRecord{AccountID: "account-1", Balances: map[uint32]uint32{101020256: 1000}}
wantAccountInventory := repository.AccountInventoryRecord{AccountID: "account-1", Slots: map[string]repository.ItemStack{"0:364": {ItemID: 10099775, Count: 44}}}
wantInventory := repository.InventoryRecord{CharacterID: "19", Slots: map[string]repository.ItemStack{"0:65": {ItemID: 700, Count: 1}}}
wantEquipment := repository.EquipmentRecord{CharacterID: "19", Entries: map[string]repository.EquipmentEntry{"weapon": {ItemID: 1001}}}
if err := repositories.EpicBook.Save(ctx, wantBook); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, wantAccountInventory); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, wantInventory); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, wantEquipment); err != nil {
t.Fatal(err)
}
wantErr := errors.New("abort epic book craft")
err := repositories.EpicBookCraft.WithinEpicBookCraft(ctx, "account-1", "19", func(
epicBooks repository.EpicBookRepository,
accountInventories repository.AccountInventoryRepository,
inventories repository.InventoryRepository,
equipment repository.EquipmentRepository,
) error {
book, _, _ := epicBooks.Load(ctx, "account-1")
book.Balances[101020256] = 0
if saveErr := epicBooks.Save(ctx, book); saveErr != nil {
return saveErr
}
accountInventory, _, _ := accountInventories.Load(ctx, "account-1")
delete(accountInventory.Slots, "0:364")
if saveErr := accountInventories.Save(ctx, accountInventory); saveErr != nil {
return saveErr
}
inventory, _, _ := inventories.Load(ctx, "19")
delete(inventory.Slots, "0:65")
if saveErr := inventories.Save(ctx, inventory); saveErr != nil {
return saveErr
}
worn, _, _ := equipment.Load(ctx, "19")
delete(worn.Entries, "weapon")
if saveErr := equipment.Save(ctx, worn); saveErr != nil {
return saveErr
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinEpicBookCraft() error=%v", err)
}
gotBook, _, _ := repositories.EpicBook.Load(ctx, "account-1")
gotAccountInventory, _, _ := repositories.AccountInventory.Load(ctx, "account-1")
gotInventory, _, _ := repositories.Inventory.Load(ctx, "19")
gotEquipment, _, _ := repositories.Equipment.Load(ctx, "19")
if !reflect.DeepEqual(gotBook, wantBook) || !reflect.DeepEqual(gotAccountInventory, wantAccountInventory) || !reflect.DeepEqual(gotInventory, wantInventory) || !reflect.DeepEqual(gotEquipment, wantEquipment) {
t.Fatalf("mutation escaped rollback book=%+v account_inventory=%+v inventory=%+v equipment=%+v", gotBook, gotAccountInventory, gotInventory, gotEquipment)
}
}
@@ -0,0 +1,151 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"testing"
"longheng.io/server/internal/platform/db"
)
func TestMemoryGroupStoresDetachedRecords(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
if err := repos.Check(ctx); err != nil {
t.Fatalf("Check() error = %v", err)
}
account := repository.AccountRecord{
AccountID: "acc-1",
HonorExp: 987654321,
Metadata: map[string]string{"source": "test"},
}
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatalf("save account: %v", err)
}
account.Metadata["source"] = "caller-mutated"
loaded, ok, err := repos.Account.Load(ctx, "acc-1")
if err != nil || !ok {
t.Fatalf("load account: ok=%v err=%v", ok, err)
}
if loaded.Metadata["source"] != "test" || loaded.HonorExp != 987654321 {
t.Fatalf("account metadata should be detached: %+v", loaded.Metadata)
}
loaded.Metadata["source"] = "loaded-mutated"
again, _, _ := repos.Account.Load(ctx, "acc-1")
if again.Metadata["source"] != "test" {
t.Fatalf("loaded mutation polluted store: %+v", again.Metadata)
}
}
func TestMemoryAccountRepresentNameIsUnique(t *testing.T) {
repos := NewMemoryGroup()
ctx := context.Background()
if err := repos.Account.Save(ctx, repository.AccountRecord{AccountID: "acc-1", RepresentAccountName: "group-one"}); err != nil {
t.Fatal(err)
}
if err := repos.Account.Save(ctx, repository.AccountRecord{AccountID: "acc-2", RepresentAccountName: "GROUP-ONE"}); !errors.Is(err, repository.ErrRepresentAccountNameExists) {
t.Fatalf("duplicate represent name error = %v", err)
}
finder := repos.Account.(repository.RepresentAccountNameFinder)
accountID, found, err := finder.FindAccountIDByRepresentName(ctx, "group-one")
if err != nil || !found || accountID != "acc-1" {
t.Fatalf("represent-name lookup account=%q found=%v err=%v", accountID, found, err)
}
}
func TestMemoryGroupStoresDetachedQuestRecords(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
record := repository.QuestRecord{
CharacterID: "quest-char-1",
States: map[int64]repository.QuestState{
100: {Status: "active", Extra: map[string]string{"source": "unit"}},
},
}
if err := repos.Quest.Save(ctx, record); err != nil {
t.Fatalf("save quest: %v", err)
}
record.States[100] = repository.QuestState{Status: "mutated"}
loaded, ok, err := repos.Quest.Load(ctx, "quest-char-1")
if err != nil || !ok {
t.Fatalf("load quest: ok=%v err=%v", ok, err)
}
if loaded.States[100].Status != "active" || loaded.States[100].Extra["source"] != "unit" {
t.Fatalf("quest state should be detached: %+v", loaded.States[100])
}
loaded.States[100].Extra["source"] = "loaded-mutated"
again, _, _ := repos.Quest.Load(ctx, "quest-char-1")
if again.States[100].Extra["source"] != "unit" {
t.Fatalf("loaded quest mutation polluted store: %+v", again.States[100])
}
}
func TestSaveFieldsFallbacksToWholeRecord(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
record := repository.CharacterRecord{
CharacterID: "char-1",
AccountID: "acc-1",
Name: "fighter",
Stats: map[string]int64{"str": 10},
}
if err := repository.SaveCharacterFields(ctx, repos.Character, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
record.Stats["str"] = 1
loaded, ok, err := repos.Character.Load(ctx, "char-1")
if err != nil || !ok {
t.Fatalf("load character: ok=%v err=%v", ok, err)
}
if loaded.Stats["str"] != 10 {
t.Fatalf("character stats should be detached: %+v", loaded.Stats)
}
}
func TestMemoryCharacterStatsPersistTutorialCompleted(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
record := repository.CharacterRecord{
CharacterID: "tutorial-char-1",
AccountID: "acc-1",
Stats: map[string]int64{"tutorial_completed": 0},
}
if err := repos.Character.Save(ctx, record); err != nil {
t.Fatalf("save character: %v", err)
}
record.Stats["tutorial_completed"] = 1
if err := repository.SaveCharacterFields(ctx, repos.Character, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
record.Stats["tutorial_completed"] = 0
loaded, ok, err := repos.Character.Load(ctx, record.CharacterID)
if err != nil || !ok {
t.Fatalf("load character: ok=%v err=%v", ok, err)
}
if got := loaded.Stats["tutorial_completed"]; got != 1 {
t.Fatalf("tutorial_completed = %d, want 1", got)
}
}
func TestGroupCheckRejectsMissingRepo(t *testing.T) {
repos := NewMemoryGroup()
repos.Skill = nil
if err := repos.Check(context.Background()); !errors.Is(err, repository.ErrRepoMissing) {
t.Fatalf("Check() error = %v, want ErrRepoMissing", err)
}
}
func TestRepositoryKeysRejectEmptyID(t *testing.T) {
repos := NewMemoryGroup()
err := repos.Inventory.Save(context.Background(), repository.InventoryRecord{})
if !errors.Is(err, db.ErrRecordKeyRequired) {
t.Fatalf("Save() error = %v, want ErrRecordKeyRequired", err)
}
}
@@ -0,0 +1,263 @@
// 本文件提供 DNF 仓储的内存实现,用于本地开发、单元测试和 SQLite
// 单进程持久化后端的领域事务复用。
package memory
import (
"context"
"fmt"
"sort"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/platform/db"
)
const StateVersion = 1
// State is a deterministic, transport-neutral snapshot of every repository
// owned by the memory aggregate. SQLite persists this value atomically; it is
// deliberately made only from domain records and contains no store internals.
type State struct {
Version int `json:"version"`
Accounts []repository.AccountRecord `json:"accounts,omitempty"`
AccountInventories []repository.AccountInventoryRecord `json:"account_inventories,omitempty"`
Characters []repository.CharacterRecord `json:"characters,omitempty"`
Inventories []repository.InventoryRecord `json:"inventories,omitempty"`
Equipment []repository.EquipmentRecord `json:"equipment,omitempty"`
Pets []repository.PetRecord `json:"pets,omitempty"`
Quests []repository.QuestRecord `json:"quests,omitempty"`
Skills []repository.SkillRecord `json:"skills,omitempty"`
DungeonPermissions []repository.DungeonPermissionRecord `json:"dungeon_permissions,omitempty"`
HellPartyPermissions []repository.HellPartyPermissionRecord `json:"hell_party_permissions,omitempty"`
PacketTemplates []repository.PacketTemplateRecord `json:"packet_templates,omitempty"`
Settings []repository.SettingsRecord `json:"settings,omitempty"`
Mailboxes []repository.MailboxRecord `json:"mailboxes,omitempty"`
EpicBooks []repository.EpicBookRecord `json:"epic_books,omitempty"`
UnitedFriends []repository.UnitedFriendRecord `json:"united_friends,omitempty"`
}
// Runtime owns the concrete memory stores so callers can atomically snapshot
// or rebuild the complete aggregate without exposing mutable map internals.
type Runtime struct {
group repository.Group
accounts *memoryAccountStore
accountInventory *db.MemoryStore[repository.AccountInventoryRecord]
characters *memoryCharStore
inventory *db.MemoryStore[repository.InventoryRecord]
equipment *db.MemoryStore[repository.EquipmentRecord]
pets *db.MemoryStore[repository.PetRecord]
quests *db.MemoryStore[repository.QuestRecord]
skills *db.MemoryStore[repository.SkillRecord]
dungeonPermissions *memoryDungeonPermissionStore
hellPartyPermissions *memoryHellPartyPermissionStore
packetTemplates *db.MemoryStore[repository.PacketTemplateRecord]
settings *db.MemoryStore[repository.SettingsRecord]
mailboxes *db.MemoryStore[repository.MailboxRecord]
epicBooks *db.MemoryStore[repository.EpicBookRecord]
unitedFriends *memoryUnitedFriendStore
}
// NewMemoryGroup creates the complete volatile repository aggregate.
func NewMemoryGroup() repository.Group {
return NewRuntime().Group()
}
// NewRuntime creates an empty memory aggregate with all repository and unit-
// of-work owners wired to the same stores.
func NewRuntime() *Runtime {
accounts := newMemoryAccountStore()
accountInventory := db.NewMemoryStore(repository.AccountInventoryKey, repository.CloneAccountInventory)
characters := newMemoryCharStore()
inventory := db.NewMemoryStore(repository.InventoryKey, repository.CloneInventory)
equipment := db.NewMemoryStore(repository.EquipmentKey, repository.CloneEquipment)
pets := db.NewMemoryStore(repository.PetKey, repository.ClonePet)
quests := db.NewMemoryStore(repository.QuestKey, repository.CloneQuest)
skills := db.NewMemoryStore(repository.SkillKey, repository.CloneSkill)
dungeonPermissions := newMemoryDungeonPermissionStore()
hellPartyPermissions := newMemoryHellPartyPermissionStore()
packetTemplates := db.NewMemoryStore(repository.PacketTemplateKey, repository.ClonePacketTemplate)
settings := db.NewMemoryStore(repository.SettingsKey, repository.CloneSettings)
mailboxes := db.NewMemoryStore(repository.MailboxKey, repository.CloneMailbox)
epicBooks := db.NewMemoryStore(repository.EpicBookKey, repository.CloneEpicBook)
unitedFriends := newMemoryUnitedFriendStore()
aggregateTxMu := &sync.Mutex{}
runtime := &Runtime{
accounts: accounts,
accountInventory: accountInventory,
characters: characters,
inventory: inventory,
equipment: equipment,
pets: pets,
quests: quests,
skills: skills,
dungeonPermissions: dungeonPermissions,
hellPartyPermissions: hellPartyPermissions,
packetTemplates: packetTemplates,
settings: settings,
mailboxes: mailboxes,
epicBooks: epicBooks,
unitedFriends: unitedFriends,
}
runtime.group = repository.Group{
Account: accounts,
AccountInventory: accountInventory,
Character: characters,
Inventory: inventory,
Equipment: equipment,
Pet: pets,
Quest: quests,
Skill: skills,
DungeonPermission: dungeonPermissions,
HellPartyPermission: hellPartyPermissions,
PacketTemplate: packetTemplates,
Settings: settings,
Mailbox: mailboxes,
EpicBook: epicBooks,
UnitedFriend: unitedFriends,
CharacterCreate: &memoryCharacterCreationUnitOfWork{},
CharacterItems: &memoryCharacterItemUnitOfWork{sharedMu: aggregateTxMu, inventory: inventory, equipment: equipment},
CharacterTrade: &memoryCharacterTradeUnitOfWork{sharedMu: aggregateTxMu, character: characters, inventory: inventory},
AccountCharacterTrade: &memoryAccountCharacterTradeUnitOfWork{sharedMu: aggregateTxMu, accountInventory: accountInventory, character: characters, inventory: inventory, epicBook: epicBooks, pets: pets},
AccountPairTrade: &memoryAccountPairCharacterTradeUnitOfWork{sharedMu: aggregateTxMu, accountInventory: accountInventory, character: characters, inventory: inventory},
CharacterPets: &memoryCharacterPetUnitOfWork{sharedMu: aggregateTxMu, inventory: inventory, equipment: equipment, pets: pets},
AccountItems: &memoryAccountCharacterItemUnitOfWork{sharedMu: aggregateTxMu, accountInventory: accountInventory, inventory: inventory},
CharacterAssets: &memoryCharacterAssetUnitOfWork{sharedMu: aggregateTxMu, character: characters, inventory: inventory, equipment: equipment},
MailboxAssets: &memoryMailboxAssetUnitOfWork{sharedMu: aggregateTxMu, account: accounts, accountInventory: accountInventory, character: characters, inventory: inventory, mailbox: mailboxes, epicBook: epicBooks},
AccountAssets: &memoryAccountCharacterAssetUnitOfWork{sharedMu: aggregateTxMu, account: accounts, accountInventory: accountInventory, character: characters, inventory: inventory, equipment: equipment},
BoosterAssets: &memoryBoosterAssetUnitOfWork{sharedMu: aggregateTxMu, account: accounts, accountInventory: accountInventory, character: characters, inventory: inventory, equipment: equipment, epicBook: epicBooks},
RentalAssets: &memoryRentalAssetUnitOfWork{sharedMu: aggregateTxMu, account: accounts, character: characters, inventory: inventory, equipment: equipment},
CeraShopAssets: &memoryCeraShopAssetUnitOfWork{sharedMu: aggregateTxMu, account: accounts, accountInventory: accountInventory, character: characters, inventory: inventory, equipment: equipment, settings: settings},
CharacterSkills: &memoryCharacterSkillUnitOfWork{sharedMu: aggregateTxMu, skills: skills},
CharacterProgression: &memoryCharacterProgressionUnitOfWork{sharedMu: aggregateTxMu, character: characters, skills: skills},
CharacterSettlement: &memoryCharacterSettlementUnitOfWork{sharedMu: aggregateTxMu, account: accounts, accountInventory: accountInventory, character: characters, quests: quests, inventory: inventory, equipment: equipment, skills: skills, settings: settings, hellPartyPermissions: hellPartyPermissions},
EpicBookCraft: &memoryEpicBookCraftUnitOfWork{sharedMu: aggregateTxMu, epicBook: epicBooks, accountInventory: accountInventory, inventory: inventory, equipment: equipment},
RaidEntryCost: &memoryRaidEntryCostUnitOfWork{sharedMu: aggregateTxMu, inventory: inventory},
RaidRewards: &memoryRaidRewardUnitOfWork{sharedMu: aggregateTxMu, inventory: inventory, settings: settings},
}
return runtime
}
// NewRuntimeFromState validates and restores one complete snapshot.
func NewRuntimeFromState(ctx context.Context, state State) (*Runtime, error) {
if state.Version != 0 && state.Version != StateVersion {
return nil, fmt.Errorf("unsupported memory repository state version=%d", state.Version)
}
runtime := NewRuntime()
group := runtime.Group()
for _, record := range state.Accounts {
if err := group.Account.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore account %q: %w", record.AccountID, err)
}
}
for _, record := range state.AccountInventories {
if err := group.AccountInventory.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore account inventory %q: %w", record.AccountID, err)
}
}
for _, record := range state.Characters {
if err := group.Character.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore character %q: %w", record.CharacterID, err)
}
}
for _, record := range state.Inventories {
if err := group.Inventory.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore inventory %q: %w", record.CharacterID, err)
}
}
for _, record := range state.Equipment {
if err := group.Equipment.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore equipment %q: %w", record.CharacterID, err)
}
}
for _, record := range state.Pets {
if err := group.Pet.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore pet %q: %w", record.CharacterID, err)
}
}
for _, record := range state.Quests {
if err := group.Quest.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore quest %q: %w", record.CharacterID, err)
}
}
for _, record := range state.Skills {
if err := group.Skill.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore skill %q: %w", record.CharacterID, err)
}
}
for _, record := range state.PacketTemplates {
if err := group.PacketTemplate.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore packet template %q: %w", record.TemplateID, err)
}
}
for _, record := range state.Settings {
if err := group.Settings.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore settings %q: %w", record.Scope, err)
}
}
for _, record := range state.Mailboxes {
if err := group.Mailbox.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore mailbox %q: %w", record.CharacterID, err)
}
}
for _, record := range state.EpicBooks {
if err := group.EpicBook.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore epic book %q: %w", record.AccountID, err)
}
}
for _, record := range state.UnitedFriends {
if err := group.UnitedFriend.Save(ctx, record); err != nil {
return nil, fmt.Errorf("restore united friend %q: %w", record.OwnerName, err)
}
}
runtime.dungeonPermissions.replace(state.DungeonPermissions)
runtime.hellPartyPermissions.replace(state.HellPartyPermissions)
return runtime, nil
}
func (r *Runtime) Group() repository.Group {
if r == nil {
return repository.Group{}
}
return r.group
}
// Snapshot returns stable key-ordered records suitable for deterministic JSON.
func (r *Runtime) Snapshot() State {
if r == nil {
return State{Version: StateVersion}
}
state := State{
Version: StateVersion,
Accounts: r.accounts.Snapshot(nil),
AccountInventories: r.accountInventory.Snapshot(nil),
Characters: r.characters.Snapshot(nil),
Inventories: r.inventory.Snapshot(nil),
Equipment: r.equipment.Snapshot(nil),
Pets: r.pets.Snapshot(nil),
Quests: r.quests.Snapshot(nil),
Skills: r.skills.Snapshot(nil),
DungeonPermissions: r.dungeonPermissions.snapshot(),
HellPartyPermissions: r.hellPartyPermissions.snapshot(),
PacketTemplates: r.packetTemplates.Snapshot(nil),
Settings: r.settings.Snapshot(nil),
Mailboxes: r.mailboxes.Snapshot(nil),
EpicBooks: r.epicBooks.Snapshot(nil),
UnitedFriends: r.unitedFriends.Snapshot(nil),
}
sort.Slice(state.Accounts, func(i, j int) bool { return state.Accounts[i].AccountID < state.Accounts[j].AccountID })
sort.Slice(state.AccountInventories, func(i, j int) bool {
return state.AccountInventories[i].AccountID < state.AccountInventories[j].AccountID
})
sort.Slice(state.Characters, func(i, j int) bool { return state.Characters[i].CharacterID < state.Characters[j].CharacterID })
sort.Slice(state.Inventories, func(i, j int) bool { return state.Inventories[i].CharacterID < state.Inventories[j].CharacterID })
sort.Slice(state.Equipment, func(i, j int) bool { return state.Equipment[i].CharacterID < state.Equipment[j].CharacterID })
sort.Slice(state.Pets, func(i, j int) bool { return state.Pets[i].CharacterID < state.Pets[j].CharacterID })
sort.Slice(state.Quests, func(i, j int) bool { return state.Quests[i].CharacterID < state.Quests[j].CharacterID })
sort.Slice(state.Skills, func(i, j int) bool { return state.Skills[i].CharacterID < state.Skills[j].CharacterID })
sort.Slice(state.PacketTemplates, func(i, j int) bool { return state.PacketTemplates[i].TemplateID < state.PacketTemplates[j].TemplateID })
sort.Slice(state.Settings, func(i, j int) bool { return state.Settings[i].Scope < state.Settings[j].Scope })
sort.Slice(state.Mailboxes, func(i, j int) bool { return state.Mailboxes[i].CharacterID < state.Mailboxes[j].CharacterID })
sort.Slice(state.EpicBooks, func(i, j int) bool { return state.EpicBooks[i].AccountID < state.EpicBooks[j].AccountID })
sort.Slice(state.UnitedFriends, func(i, j int) bool { return state.UnitedFriends[i].OwnerName < state.UnitedFriends[j].OwnerName })
return state
}
@@ -0,0 +1,159 @@
package memory
import (
"context"
"encoding/json"
"longheng.io/server/internal/modules/dnf/repository"
"sort"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/db"
)
func (s *memoryAccountStore) FindAccountIDByPVPTotalTeamName(ctx context.Context, name string) (string, bool, error) {
if err := ctxErr(ctx); err != nil {
return "", false, err
}
for _, record := range s.Snapshot(nil) {
var team struct {
Name string `json:"name"`
}
if json.Unmarshal([]byte(record.Metadata["pvp_total_match_team_v1"]), &team) == nil && strings.EqualFold(team.Name, name) {
return record.AccountID, true, nil
}
}
return "", false, nil
}
func (s *memoryAccountStore) ListAccounts(ctx context.Context, query string, limit int) ([]repository.AccountRecord, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
query = strings.ToLower(strings.TrimSpace(query))
if limit <= 0 {
limit = 50
}
records := s.Snapshot(func(left, right repository.AccountRecord) bool {
if !left.UpdatedAt.Equal(right.UpdatedAt) {
return left.UpdatedAt.After(right.UpdatedAt)
}
return left.AccountID < right.AccountID
})
out := make([]repository.AccountRecord, 0, min(limit, len(records)))
for _, record := range records {
if query != "" && !strings.Contains(strings.ToLower(record.AccountID), query) &&
!strings.Contains(strings.ToLower(record.RepresentAccountName), query) {
continue
}
out = append(out, repository.CloneAccount(record))
if len(out) >= limit {
break
}
}
sort.SliceStable(out, func(i, j int) bool {
if !out[i].UpdatedAt.Equal(out[j].UpdatedAt) {
return out[i].UpdatedAt.After(out[j].UpdatedAt)
}
return out[i].AccountID < out[j].AccountID
})
return out, nil
}
type memoryAccountStore struct {
*db.MemoryStore[repository.AccountRecord]
nameMu sync.Mutex
}
func newMemoryAccountStore() *memoryAccountStore {
return &memoryAccountStore{
MemoryStore: db.NewMemoryStore(repository.AccountKey, repository.CloneAccount),
}
}
func (s *memoryAccountStore) Save(ctx context.Context, record repository.AccountRecord) error {
if err := ctxErr(ctx); err != nil {
return err
}
s.nameMu.Lock()
defer s.nameMu.Unlock()
name := strings.TrimSpace(record.RepresentAccountName)
if name != "" {
for _, existing := range s.Snapshot(nil) {
if existing.AccountID != record.AccountID && strings.EqualFold(strings.TrimSpace(existing.RepresentAccountName), name) {
return repository.ErrRepresentAccountNameExists
}
}
}
record.RepresentAccountName = name
return s.MemoryStore.Save(ctx, record)
}
func (s *memoryAccountStore) SaveMetadataEntry(
ctx context.Context,
accountID string,
key string,
value string,
updatedAt time.Time,
) error {
if err := ctxErr(ctx); err != nil {
return err
}
accountID = strings.TrimSpace(accountID)
key = strings.TrimSpace(key)
if accountID == "" {
return db.ErrRecordKeyRequired
}
if key == "" {
return repository.ErrAccountMetadataKeyRequired
}
s.nameMu.Lock()
defer s.nameMu.Unlock()
record, found, err := s.MemoryStore.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return repository.ErrAccountMetadataUnavailable
}
record = repository.CloneAccount(record)
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
record.Metadata[key] = value
record.UpdatedAt = updatedAt.UTC()
return s.MemoryStore.Save(ctx, record)
}
func (s *memoryAccountStore) FindAccountIDByRepresentName(ctx context.Context, name string) (string, bool, error) {
if err := ctxErr(ctx); err != nil {
return "", false, err
}
name = strings.TrimSpace(name)
if name == "" {
return "", false, nil
}
for _, record := range s.Snapshot(nil) {
if strings.EqualFold(strings.TrimSpace(record.RepresentAccountName), name) {
return record.AccountID, true, nil
}
}
return "", false, nil
}
func (s *memoryAccountStore) FindAccountIDByLoginUsername(ctx context.Context, username string) (string, bool, error) {
if err := ctxErr(ctx); err != nil {
return "", false, err
}
username = strings.ToLower(strings.TrimSpace(username))
if username == "" {
return "", false, nil
}
for _, record := range s.Snapshot(nil) {
if strings.EqualFold(strings.TrimSpace(record.Metadata["login_username"]), username) {
return record.AccountID, true, nil
}
}
return "", false, nil
}
@@ -0,0 +1,362 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"strings"
"sync"
)
type memoryAccountCharacterItemUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
accountInventory repository.AccountInventoryRepository
inventory repository.InventoryRepository
}
type memoryAccountCharacterAssetUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
account repository.AccountRepository
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
inventory repository.InventoryRepository
equipment repository.EquipmentRepository
}
func (u *memoryAccountCharacterItemUnitOfWork) WithinAccountCharacterItems(
ctx context.Context,
accountID string,
characterID string,
apply func(repository.AccountInventoryRepository, repository.InventoryRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if accountID == "" || characterID == "" || apply == nil || u == nil || u.accountInventory == nil || u.inventory == nil {
return repository.ErrAccountCharacterItemTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
accountRecord, accountExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
characterRecord, characterExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
accountTx := &memoryAccountInventoryTransaction{
accountID: accountID,
record: repository.CloneAccountInventory(accountRecord),
exists: accountExists,
}
characterTx := &memoryInventoryTransaction{
characterID: characterID,
record: repository.CloneInventory(characterRecord),
exists: characterExists,
}
if err := apply(accountTx, characterTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
// Save the character side first. If the account side then fails, restore
// both preimages so the in-memory implementation has SQL-like atomicity.
if characterTx.dirty {
if err := u.inventory.Save(ctx, characterTx.record); err != nil {
return errors.Join(err, restoreInventoryRecord(u.inventory, characterID, characterRecord, characterExists))
}
}
if accountTx.dirty {
if err := u.accountInventory.Save(ctx, accountTx.record); err != nil {
return errors.Join(
err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountRecord, accountExists),
restoreInventoryRecordIfDirty(u.inventory, characterID, characterRecord, characterExists, characterTx.dirty),
)
}
}
return nil
}
func (u *memoryAccountCharacterAssetUnitOfWork) WithinAccountCharacterAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if accountID == "" || characterID == "" || apply == nil || u == nil ||
u.accountInventory == nil || u.character == nil || u.inventory == nil || u.equipment == nil {
return repository.ErrAccountCharacterAssetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
accountRecord, accountExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
characterRecord, characterExists, err := u.character.Load(ctx, characterID)
if err != nil {
return err
}
inventoryRecord, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipmentRecord, equipmentExists, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
accountTx := &memoryAccountInventoryTransaction{
accountID: accountID,
record: repository.CloneAccountInventory(accountRecord),
exists: accountExists,
}
characterTx := &memoryCharacterTransaction{
characterID: characterID,
record: repository.CloneCharacter(characterRecord),
exists: characterExists,
}
inventoryTx := &memoryInventoryTransaction{
characterID: characterID,
record: repository.CloneInventory(inventoryRecord),
exists: inventoryExists,
}
equipmentTx := &memoryEquipmentTransaction{
characterID: characterID,
record: repository.CloneEquipment(equipmentRecord),
exists: equipmentExists,
}
if err := apply(accountTx, characterTx, inventoryTx, equipmentTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if accountTx.dirty {
if err := u.accountInventory.Save(ctx, accountTx.record); err != nil {
return errors.Join(err, restoreAccountInventoryRecord(u.accountInventory, accountID, accountRecord, accountExists))
}
}
if characterTx.dirty {
if err := u.character.Save(ctx, characterTx.record); err != nil {
return errors.Join(
err,
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, true),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(
err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, true),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
if equipmentTx.dirty {
if err := u.equipment.Save(ctx, equipmentTx.record); err != nil {
return errors.Join(
err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipmentRecord, equipmentExists, true),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, inventoryTx.dirty),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
return nil
}
// WithinShopPurchaseAssets 是商店购买限购用的事务边界:在
// WithinAccountCharacterAssets 的同一批记录之外,再交出账号记录本身,用于账号
// 维度的限购计数(账号记录 Metadata)。内存实现沿用同一把共享锁与 preimage
// 回滚链,保证与 SQL 实现同样的原子性。
func (u *memoryAccountCharacterAssetUnitOfWork) WithinShopPurchaseAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if accountID == "" || characterID == "" || apply == nil || u == nil ||
u.account == nil || u.accountInventory == nil || u.character == nil || u.inventory == nil {
return repository.ErrShopPurchaseAssetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
accountRecord, accountExists, err := u.account.Load(ctx, accountID)
if err != nil {
return err
}
accountInventoryRecord, accountInventoryExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
characterRecord, characterExists, err := u.character.Load(ctx, characterID)
if err != nil {
return err
}
inventoryRecord, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
accountTx := &memoryAccountTransaction{
accountID: accountID,
record: repository.CloneAccount(accountRecord),
exists: accountExists,
}
accountInventoryTx := &memoryAccountInventoryTransaction{
accountID: accountID,
record: repository.CloneAccountInventory(accountInventoryRecord),
exists: accountInventoryExists,
}
characterTx := &memoryCharacterTransaction{
characterID: characterID,
record: repository.CloneCharacter(characterRecord),
exists: characterExists,
}
inventoryTx := &memoryInventoryTransaction{
characterID: characterID,
record: repository.CloneInventory(inventoryRecord),
exists: inventoryExists,
}
if err := apply(accountTx, accountInventoryTx, characterTx, inventoryTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if accountTx.dirty {
if err := u.account.Save(ctx, accountTx.record); err != nil {
return errors.Join(err, restoreAccountRecord(u.account, accountID, accountRecord, accountExists, true))
}
}
if accountInventoryTx.dirty {
if err := u.accountInventory.Save(ctx, accountInventoryTx.record); err != nil {
return errors.Join(
err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountInventoryRecord, accountInventoryExists),
restoreAccountRecord(u.account, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
if characterTx.dirty {
if err := u.character.Save(ctx, characterTx.record); err != nil {
return errors.Join(
err,
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, true),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventoryRecord, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(
err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, true),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventoryRecord, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, accountRecord, accountExists, accountTx.dirty),
)
}
}
return nil
}
type memoryAccountInventoryTransaction struct {
accountID string
record repository.AccountInventoryRecord
exists bool
dirty bool
}
func (tx *memoryAccountInventoryTransaction) Load(ctx context.Context, accountID string) (repository.AccountInventoryRecord, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.accountID, accountID); err != nil {
return repository.AccountInventoryRecord{}, false, err
}
return repository.CloneAccountInventory(tx.record), tx.exists, nil
}
func (tx *memoryAccountInventoryTransaction) Save(ctx context.Context, record repository.AccountInventoryRecord) error {
if err := repository.TransactionKeyError(ctx, tx.accountID, record.AccountID); err != nil {
return err
}
tx.record = repository.CloneAccountInventory(record)
tx.exists = true
tx.dirty = true
return nil
}
func restoreAccountInventoryRecord(repo repository.AccountInventoryRepository, accountID string, record repository.AccountInventoryRecord, existed bool) error {
if repo == nil {
return nil
}
if existed {
return repo.Save(context.Background(), record)
}
deleter, ok := repo.(interface {
Delete(context.Context, string) error
})
if !ok {
return errors.New("account inventory rollback cannot remove newly created record")
}
return deleter.Delete(context.Background(), accountID)
}
func restoreAccountInventoryRecordIfDirty(repo repository.AccountInventoryRepository, accountID string, record repository.AccountInventoryRecord, existed bool, dirty bool) error {
if !dirty {
return nil
}
return restoreAccountInventoryRecord(repo, accountID, record, existed)
}
@@ -0,0 +1,146 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
// memoryBoosterAssetUnitOfWork mirrors the SQL booster transaction boundary:
// shared inventory, character, ordinary items, equipment and Epic Book are
// committed or restored as one aggregate.
type memoryBoosterAssetUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
account repository.AccountRepository
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
inventory repository.InventoryRepository
equipment repository.EquipmentRepository
epicBook repository.EpicBookRepository
}
func (u *memoryBoosterAssetUnitOfWork) WithinBoosterAssets(
ctx context.Context,
accountID string,
characterID string,
apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository, repository.EpicBookRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if u == nil || accountID == "" || characterID == "" || apply == nil ||
u.account == nil || u.accountInventory == nil || u.character == nil || u.inventory == nil || u.equipment == nil || u.epicBook == nil {
return repository.ErrBoosterAssetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
accountState, accountStateExists, err := u.account.Load(ctx, accountID)
if err != nil {
return err
}
accountRecord, accountExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
characterRecord, characterExists, err := u.character.Load(ctx, characterID)
if err != nil {
return err
}
inventoryRecord, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipmentRecord, equipmentExists, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
epicBookRecord, epicBookExists, err := u.epicBook.Load(ctx, accountID)
if err != nil {
return err
}
accountStateTx := &memoryAccountTransaction{accountID: accountID, record: repository.CloneAccount(accountState), exists: accountStateExists}
accountTx := &memoryAccountInventoryTransaction{accountID: accountID, record: repository.CloneAccountInventory(accountRecord), exists: accountExists}
characterTx := &memoryCharacterTransaction{characterID: characterID, record: repository.CloneCharacter(characterRecord), exists: characterExists}
inventoryTx := &memoryInventoryTransaction{characterID: characterID, record: repository.CloneInventory(inventoryRecord), exists: inventoryExists}
equipmentTx := &memoryEquipmentTransaction{characterID: characterID, record: repository.CloneEquipment(equipmentRecord), exists: equipmentExists}
epicBookTx := &memoryEpicBookTransaction{accountID: accountID, record: repository.CloneEpicBook(epicBookRecord), exists: epicBookExists}
if err := apply(accountStateTx, accountTx, characterTx, inventoryTx, equipmentTx, epicBookTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if accountStateTx.dirty {
if err := u.account.Save(ctx, accountStateTx.record); err != nil {
return errors.Join(err, restoreAccountRecord(u.account, accountID, accountState, accountStateExists, true))
}
}
if accountTx.dirty {
if err := u.accountInventory.Save(ctx, accountTx.record); err != nil {
return errors.Join(err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountRecord, accountExists),
restoreAccountRecord(u.account, accountID, accountState, accountStateExists, accountStateTx.dirty),
)
}
}
if characterTx.dirty {
if err := u.character.Save(ctx, characterTx.record); err != nil {
return errors.Join(err,
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, true),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
restoreAccountRecord(u.account, accountID, accountState, accountStateExists, accountStateTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, true),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
restoreAccountRecord(u.account, accountID, accountState, accountStateExists, accountStateTx.dirty),
)
}
}
if equipmentTx.dirty {
if err := u.equipment.Save(ctx, equipmentTx.record); err != nil {
return errors.Join(err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipmentRecord, equipmentExists, true),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, inventoryTx.dirty),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
restoreAccountRecord(u.account, accountID, accountState, accountStateExists, accountStateTx.dirty),
)
}
}
if epicBookTx.dirty {
if err := u.epicBook.Save(ctx, epicBookTx.record); err != nil {
return errors.Join(err,
restoreEpicBookRecord(u.epicBook, accountID, epicBookRecord, epicBookExists),
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipmentRecord, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventoryRecord, inventoryExists, inventoryTx.dirty),
restoreCharacterRecord(u.character, characterID, characterRecord, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountRecord, accountExists, accountTx.dirty),
restoreAccountRecord(u.account, accountID, accountState, accountStateExists, accountStateTx.dirty),
)
}
}
return nil
}
@@ -0,0 +1,306 @@
// 本文件提供角色仓储的内存实现。
// 它只服务本地开发和测试,不作为生产持久化后端。
package memory
import (
"context"
"longheng.io/server/internal/modules/dnf/repository"
"strconv"
"strings"
"sync"
"longheng.io/server/internal/platform/db"
)
type memoryCharStore struct {
*db.MemoryStore[repository.CharacterRecord]
storyDigestMu sync.Mutex
rosterMu sync.Mutex
}
func newMemoryCharStore() *memoryCharStore {
return &memoryCharStore{
MemoryStore: db.NewMemoryStore(repository.CharacterKey, repository.CloneCharacter),
}
}
// Save preserves the two monotonic story-summary scalars when an unrelated
// character-stats writer saves a stale aggregate snapshot.
func (s *memoryCharStore) Save(ctx context.Context, record repository.CharacterRecord) error {
s.storyDigestMu.Lock()
defer s.storyDigestMu.Unlock()
return s.savePreservingStoryDigest(ctx, record)
}
func (s *memoryCharStore) savePreservingStoryDigest(ctx context.Context, record repository.CharacterRecord) error {
if existing, found, err := s.MemoryStore.Load(ctx, record.CharacterID); err != nil {
return err
} else if found {
if record.Stats == nil {
record.Stats = make(map[string]int64, 2)
}
for _, key := range []string{
repository.CharacterStoryDigestLastLevelStatKey,
repository.CharacterStoryDigestMigrationVersionStatKey,
} {
if existing.Stats[key] > record.Stats[key] {
record.Stats[key] = existing.Stats[key]
}
}
}
return s.MemoryStore.Save(ctx, record)
}
func (s *memoryCharStore) AdvanceStoryDigest(ctx context.Context, characterID string, level, migrationVersion uint32) error {
if err := ctxErr(ctx); err != nil {
return err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return repository.ErrCharacterStoryDigestCharacterMissing
}
s.storyDigestMu.Lock()
defer s.storyDigestMu.Unlock()
record, ok, err := s.Load(ctx, characterID)
if err != nil {
return err
}
if !ok {
return repository.ErrCharacterStoryDigestCharacterMissing
}
if record.Stats == nil {
record.Stats = make(map[string]int64, 2)
}
if int64(level) > record.Stats[repository.CharacterStoryDigestLastLevelStatKey] {
record.Stats[repository.CharacterStoryDigestLastLevelStatKey] = int64(level)
}
if int64(migrationVersion) > record.Stats[repository.CharacterStoryDigestMigrationVersionStatKey] {
record.Stats[repository.CharacterStoryDigestMigrationVersionStatKey] = int64(migrationVersion)
}
return s.MemoryStore.Save(ctx, record)
}
// CreateCharacter 只用于测试/本地开发的新建路径,模拟 MySQL 的主键和槽位唯一约束。
func (s *memoryCharStore) CreateCharacter(ctx context.Context, record repository.CharacterRecord) error {
if _, ok, err := s.Load(ctx, record.CharacterID); err != nil || ok {
if err != nil {
return err
}
return repository.ErrCharacterIDExists
}
records, err := s.ListByAccount(ctx, record.AccountID, repository.MaxCharacterSlotLimit)
if err != nil {
return err
}
for _, existing := range records {
if existing.Slot == record.Slot {
return repository.ErrCharacterSlotOccupied
}
}
return s.Save(ctx, record)
}
// ListByAccount 从内存快照中按槽位顺序返回账号角色列表。
// 该实现只用于测试和本地开发,不写 MySQL/Redis,也不做角色创建规则判断。
func (s *memoryCharStore) ListByAccount(ctx context.Context, accountID string, limit int) ([]repository.CharacterRecord, error) {
if err := ctxErr(ctx); err != nil {
return nil, err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, db.ErrRecordKeyRequired
}
if limit <= 0 {
// 不传 limit 的调用方要的是“这个账号的全部角色”,回落值必须是硬上限;
// 回落到基线会静默截掉扩栏槽位上的角色。
limit = repository.MaxCharacterSlotLimit
}
records := s.Snapshot(func(a, b repository.CharacterRecord) bool {
if a.Slot == b.Slot {
return a.CharacterID < b.CharacterID
}
return a.Slot < b.Slot
})
out := make([]repository.CharacterRecord, 0, minInt(limit, len(records)))
for _, record := range records {
if record.AccountID != accountID {
continue
}
if characterDeleteFlag(record) != 0 {
continue
}
out = append(out, repository.CloneCharacter(record))
if len(out) >= limit {
break
}
}
return out, nil
}
func (s *memoryCharStore) LastDeletedProfession(ctx context.Context, accountID string, job, grow byte) (repository.CharacterRecord, bool, error) {
if err := ctxErr(ctx); err != nil {
return repository.CharacterRecord{}, false, err
}
var latest repository.CharacterRecord
found := false
for _, record := range s.Snapshot(nil) {
if record.AccountID == accountID && record.Job == strconv.Itoa(int(job)) && byte(record.Stats["grow_type"])&15 == grow && characterDeleteFlag(record) != 0 &&
(!found || record.UpdatedAt.After(latest.UpdatedAt)) {
latest, found = repository.CloneCharacter(record), true
}
}
return latest, found, nil
}
func (s *memoryCharStore) SwapCharacterSlots(ctx context.Context, accountID string, slotA, slotB int) error {
if err := ctxErr(ctx); err != nil {
return err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return db.ErrRecordKeyRequired
}
if slotA == slotB {
return nil
}
if slotA < 0 || slotA >= repository.MaxCharacterSlotLimit || slotB < 0 || slotB >= repository.MaxCharacterSlotLimit {
return repository.ErrCharacterSlotMissing
}
records, err := s.ListByAccount(ctx, accountID, repository.MaxCharacterSlotLimit)
if err != nil {
return err
}
var left, right repository.CharacterRecord
leftFound, rightFound := false, false
for _, record := range records {
switch record.Slot {
case slotA:
left, leftFound = record, true
case slotB:
right, rightFound = record, true
}
}
if !leftFound || !rightFound {
return nil
}
left.Slot, right.Slot = slotB, slotA
if err := s.Save(ctx, left); err != nil {
return err
}
if err := s.Save(ctx, right); err != nil {
left.Slot = slotA
_ = s.Save(context.Background(), left)
return err
}
return nil
}
func (s *memoryCharStore) DeleteCharacterAndCompact(ctx context.Context, accountID string, slot int, name string) error {
if err := ctxErr(ctx); err != nil {
return err
}
accountID = strings.TrimSpace(accountID)
name = strings.TrimSpace(name)
if accountID == "" || name == "" {
return db.ErrRecordKeyRequired
}
if slot < 0 || slot >= repository.MaxCharacterSlotLimit {
return repository.ErrCharacterSlotMissing
}
s.rosterMu.Lock()
defer s.rosterMu.Unlock()
records, err := s.ListByAccount(ctx, accountID, repository.MaxCharacterSlotLimit)
if err != nil {
return err
}
targetIndex := -1
for index := range records {
if records[index].Slot == slot {
targetIndex = index
break
}
}
if targetIndex < 0 {
return repository.ErrCharacterDeleteNotFound
}
if strings.TrimSpace(records[targetIndex].Name) != name {
return repository.ErrCharacterDeleteNameMismatch
}
target := records[targetIndex]
if target.Stats == nil {
target.Stats = make(map[string]int64)
}
target.Stats["delete_flag"] = 1
target.Slot = -1
if err := s.Save(ctx, target); err != nil {
return err
}
compactSlot := 0
for index := range records {
if index == targetIndex {
continue
}
record := records[index]
if record.Slot != compactSlot {
record.Slot = compactSlot
if err := s.Save(ctx, record); err != nil {
return err
}
}
compactSlot++
}
return nil
}
// FindIDByName 从内存快照中按角色名查找角色 ID。
// 这里只提供测试后端的查询能力,生产路径由 MySQL 索引和 Redis 名字缓存承担。
func (s *memoryCharStore) FindIDByName(ctx context.Context, name string) (string, bool, error) {
if err := ctxErr(ctx); err != nil {
return "", false, err
}
name = strings.TrimSpace(name)
if name == "" {
return "", false, nil
}
for _, record := range s.Snapshot(nil) {
if record.Name == name && characterDeleteFlag(record) == 0 {
return record.CharacterID, true, nil
}
}
return "", false, nil
}
func characterDeleteFlag(record repository.CharacterRecord) int64 {
if record.Stats == nil {
return 0
}
if value, ok := record.Stats["delete_flag"]; ok {
return value
}
return 0
}
// NextNumericID 扫描内存角色 ID 并返回下一个数字 ID。
// 该实现不提供跨进程唯一性,只用于测试;生产路径使用 MySQL 最大值加 Redis 原子自增。
func (s *memoryCharStore) NextNumericID(ctx context.Context) (int, error) {
if err := ctxErr(ctx); err != nil {
return 0, err
}
maxID := 0
for _, record := range s.Snapshot(nil) {
id, err := strconv.Atoi(strings.TrimSpace(record.CharacterID))
if err == nil && id > maxID {
maxID = id
}
}
return maxID + 1, nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,113 @@
package memory
import (
"context"
"longheng.io/server/internal/modules/dnf/repository"
"sort"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/db"
)
type memoryDungeonPermissionStore struct {
mu sync.RWMutex
records map[string]repository.DungeonPermissionRecord
now func() time.Time
}
func (s *memoryDungeonPermissionStore) snapshot() []repository.DungeonPermissionRecord {
s.mu.RLock()
records := make([]repository.DungeonPermissionRecord, 0, len(s.records))
for _, record := range s.records {
records = append(records, repository.CloneDungeonPermission(record))
}
s.mu.RUnlock()
sort.Slice(records, func(i, j int) bool { return records[i].CharacterID < records[j].CharacterID })
return records
}
func (s *memoryDungeonPermissionStore) replace(records []repository.DungeonPermissionRecord) {
s.mu.Lock()
s.records = make(map[string]repository.DungeonPermissionRecord, len(records))
for _, record := range records {
if key := strings.TrimSpace(record.CharacterID); key != "" {
s.records[key] = repository.CloneDungeonPermission(record)
}
}
s.mu.Unlock()
}
func newMemoryDungeonPermissionStore() *memoryDungeonPermissionStore {
return &memoryDungeonPermissionStore{records: make(map[string]repository.DungeonPermissionRecord)}
}
func (s *memoryDungeonPermissionStore) Check(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
return ctx.Err()
}
func (s *memoryDungeonPermissionStore) Load(ctx context.Context, characterID string) (repository.DungeonPermissionRecord, bool, error) {
if err := ctxErr(ctx); err != nil {
return repository.DungeonPermissionRecord{}, false, err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return repository.DungeonPermissionRecord{}, false, db.ErrRecordKeyRequired
}
s.mu.RLock()
record, ok := s.records[characterID]
s.mu.RUnlock()
if !ok {
return repository.DungeonPermissionRecord{}, false, nil
}
return repository.CloneDungeonPermission(record), true, nil
}
func (s *memoryDungeonPermissionStore) UpsertMax(
ctx context.Context,
characterID string,
dungeonID uint32,
clearState byte,
) (repository.DungeonPermissionEntry, bool, error) {
if err := ctxErr(ctx); err != nil {
return repository.DungeonPermissionEntry{}, false, err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return repository.DungeonPermissionEntry{}, false, db.ErrRecordKeyRequired
}
if dungeonID == 0 || clearState == 0 {
return repository.DungeonPermissionEntry{}, false, nil
}
now := timeOrNow(time.Time{}, s.now)
s.mu.Lock()
defer s.mu.Unlock()
record := repository.CloneDungeonPermission(s.records[characterID])
record.CharacterID = characterID
record.UpdatedAt = now
for idx := range record.Entries {
if record.Entries[idx].DungeonID != dungeonID {
continue
}
if record.Entries[idx].ClearState >= clearState {
return record.Entries[idx], false, nil
}
record.Entries[idx].ClearState = clearState
record.Entries[idx].UpdatedAt = now
s.records[characterID] = repository.CloneDungeonPermission(record)
return record.Entries[idx], true, nil
}
entry := repository.DungeonPermissionEntry{
DungeonID: dungeonID,
ClearState: clearState,
SortOrder: len(record.Entries),
UpdatedAt: now,
}
record.Entries = append(record.Entries, entry)
s.records[characterID] = repository.CloneDungeonPermission(record)
return entry, true, nil
}
@@ -0,0 +1,152 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
type memoryEpicBookCraftUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
epicBook repository.EpicBookRepository
accountInventory repository.AccountInventoryRepository
inventory repository.InventoryRepository
equipment repository.EquipmentRepository
}
func (u *memoryEpicBookCraftUnitOfWork) WithinEpicBookCraft(
ctx context.Context,
accountID string,
characterID string,
apply func(repository.EpicBookRepository, repository.AccountInventoryRepository, repository.InventoryRepository, repository.EquipmentRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
characterID = strings.TrimSpace(characterID)
if u == nil || accountID == "" || characterID == "" || apply == nil || u.epicBook == nil || u.accountInventory == nil || u.inventory == nil || u.equipment == nil {
return repository.ErrEpicBookCraftTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
epicBook, epicBookExists, err := u.epicBook.Load(ctx, accountID)
if err != nil {
return err
}
accountInventory, accountInventoryExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
inventory, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipment, equipmentExists, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
epicBookTx := &memoryEpicBookTransaction{accountID: accountID, record: repository.CloneEpicBook(epicBook), exists: epicBookExists}
accountInventoryTx := &memoryAccountInventoryTransaction{accountID: accountID, record: repository.CloneAccountInventory(accountInventory), exists: accountInventoryExists}
inventoryTx := &memoryInventoryTransaction{characterID: characterID, record: repository.CloneInventory(inventory), exists: inventoryExists}
equipmentTx := &memoryEquipmentTransaction{characterID: characterID, record: repository.CloneEquipment(equipment), exists: equipmentExists}
if err := apply(epicBookTx, accountInventoryTx, inventoryTx, equipmentTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if epicBookTx.dirty {
if err := u.epicBook.Save(ctx, epicBookTx.record); err != nil {
return errors.Join(err, restoreEpicBookRecord(u.epicBook, accountID, epicBook, epicBookExists))
}
}
if accountInventoryTx.dirty {
if err := u.accountInventory.Save(ctx, accountInventoryTx.record); err != nil {
return errors.Join(err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountInventory, accountInventoryExists),
restoreEpicBookRecordIfDirty(u.epicBook, accountID, epicBook, epicBookExists, epicBookTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, true),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreEpicBookRecordIfDirty(u.epicBook, accountID, epicBook, epicBookExists, epicBookTx.dirty),
)
}
}
if equipmentTx.dirty {
if err := u.equipment.Save(ctx, equipmentTx.record); err != nil {
return errors.Join(err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, true),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreEpicBookRecordIfDirty(u.epicBook, accountID, epicBook, epicBookExists, epicBookTx.dirty),
)
}
}
return nil
}
type memoryEpicBookTransaction struct {
accountID string
record repository.EpicBookRecord
exists bool
dirty bool
}
func (tx *memoryEpicBookTransaction) Load(ctx context.Context, accountID string) (repository.EpicBookRecord, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.accountID, accountID); err != nil {
return repository.EpicBookRecord{}, false, err
}
return repository.CloneEpicBook(tx.record), tx.exists, nil
}
func (tx *memoryEpicBookTransaction) Save(ctx context.Context, record repository.EpicBookRecord) error {
if err := repository.TransactionKeyError(ctx, tx.accountID, record.AccountID); err != nil {
return err
}
tx.record = repository.CloneEpicBook(record)
tx.exists = true
tx.dirty = true
return nil
}
func restoreEpicBookRecord(repo repository.EpicBookRepository, accountID string, record repository.EpicBookRecord, existed bool) error {
if repo == nil {
return nil
}
if existed {
return repo.Save(context.Background(), record)
}
deleter, ok := repo.(interface {
Delete(context.Context, string) error
})
if !ok {
return errors.New("epic book rollback cannot remove newly created record")
}
return deleter.Delete(context.Background(), accountID)
}
func restoreEpicBookRecordIfDirty(repo repository.EpicBookRepository, accountID string, record repository.EpicBookRecord, existed, dirty bool) error {
if !dirty {
return nil
}
return restoreEpicBookRecord(repo, accountID, record, existed)
}
@@ -0,0 +1,120 @@
package memory
import (
"context"
"longheng.io/server/internal/modules/dnf/repository"
"sort"
"strings"
"sync"
"time"
)
type memoryHellPartyPermissionStore struct {
mu sync.RWMutex
records map[string]repository.HellPartyPermissionRecord
now func() time.Time
}
func (s *memoryHellPartyPermissionStore) snapshot() []repository.HellPartyPermissionRecord {
s.mu.RLock()
records := make([]repository.HellPartyPermissionRecord, 0, len(s.records))
for _, record := range s.records {
records = append(records, repository.CloneHellPartyPermission(record))
}
s.mu.RUnlock()
sort.Slice(records, func(i, j int) bool { return records[i].CharacterID < records[j].CharacterID })
return records
}
func (s *memoryHellPartyPermissionStore) replace(records []repository.HellPartyPermissionRecord) {
s.mu.Lock()
s.records = make(map[string]repository.HellPartyPermissionRecord, len(records))
for _, record := range records {
if key := strings.TrimSpace(record.CharacterID); key != "" {
s.records[key] = repository.CloneHellPartyPermission(record)
}
}
s.mu.Unlock()
}
func newMemoryHellPartyPermissionStore() *memoryHellPartyPermissionStore {
return &memoryHellPartyPermissionStore{records: make(map[string]repository.HellPartyPermissionRecord)}
}
func (s *memoryHellPartyPermissionStore) Check(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
return ctx.Err()
}
func (s *memoryHellPartyPermissionStore) Load(ctx context.Context, characterID string) (repository.HellPartyPermissionRecord, bool, error) {
if err := ctxErr(ctx); err != nil {
return repository.HellPartyPermissionRecord{}, false, err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return repository.HellPartyPermissionRecord{}, false, nil
}
s.mu.RLock()
record, ok := s.records[characterID]
s.mu.RUnlock()
if !ok {
return repository.HellPartyPermissionRecord{}, false, nil
}
return repository.CloneHellPartyPermission(record), true, nil
}
func (s *memoryHellPartyPermissionStore) Upsert(ctx context.Context, characterID string, areaID, questID int64) (repository.HellPartyPermissionEntry, bool, error) {
if err := ctxErr(ctx); err != nil {
return repository.HellPartyPermissionEntry{}, false, err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" || areaID <= 0 || questID <= 0 {
return repository.HellPartyPermissionEntry{}, false, nil
}
now := timeOrNow(time.Time{}, s.now)
s.mu.Lock()
defer s.mu.Unlock()
record := repository.CloneHellPartyPermission(s.records[characterID])
record.CharacterID = characterID
for i := range record.Entries {
if record.Entries[i].AreaID == areaID {
return record.Entries[i], false, nil
}
}
entry := repository.HellPartyPermissionEntry{AreaID: areaID, QuestID: questID, UpdatedAt: now}
record.Entries = append(record.Entries, entry)
record.UpdatedAt = now
s.records[characterID] = repository.CloneHellPartyPermission(record)
return entry, true, nil
}
func (s *memoryHellPartyPermissionStore) Delete(ctx context.Context, characterID string, areaID int64) error {
if err := ctxErr(ctx); err != nil {
return err
}
characterID = strings.TrimSpace(characterID)
if characterID == "" || areaID <= 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.records[characterID]
if !ok {
return nil
}
filtered := record.Entries[:0]
for _, entry := range record.Entries {
if entry.AreaID != areaID {
filtered = append(filtered, entry)
}
}
if len(filtered) == len(record.Entries) {
return nil
}
record.Entries = append([]repository.HellPartyPermissionEntry(nil), filtered...)
if len(record.Entries) == 0 {
delete(s.records, characterID)
} else {
s.records[characterID] = repository.CloneHellPartyPermission(record)
}
return nil
}
@@ -0,0 +1,24 @@
// 本文件提供 DNF 内存仓储的私有通用 helper(与 mysql 实现相互独立)。
package memory
import (
"context"
"time"
)
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
func timeOrNow(value time.Time, now func() time.Time) time.Time {
if !value.IsZero() {
return value.UTC()
}
if now == nil {
return time.Now().UTC()
}
return now().UTC()
}
@@ -0,0 +1,78 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
)
func (u *memoryRentalAssetUnitOfWork) WithinPVPTotal(ctx context.Context, accountID string, roster [3]string, apply func(repository.AccountRepository, repository.CharacterRepository) error) error {
ids, err := repository.PVPTotalTransactionIDs(accountID, roster)
if err != nil {
return err
}
if u == nil || apply == nil || u.account == nil || u.character == nil {
return repository.ErrRentalAssetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
account, accountExists, err := u.account.Load(ctx, accountID)
if err != nil {
return err
}
a := &memoryAccountTransaction{accountID: accountID, record: repository.CloneAccount(account), exists: accountExists}
c := &memoryCharacterTradeCharacterTransaction{allowed: map[string]struct{}{}, records: map[string]repository.CharacterRecord{}, exists: map[string]bool{}, dirty: map[string]bool{}}
original := map[string]repository.CharacterRecord{}
for _, id := range ids {
v, found, err := u.character.Load(ctx, id)
if err != nil {
return err
}
c.allowed[id] = struct{}{}
c.records[id], c.exists[id], original[id] = repository.CloneCharacter(v), found, repository.CloneCharacter(v)
}
if err := apply(a, c); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
var committed []string
accountCommitted := false
rollback := func(cause error) error {
errs := []error{cause}
for _, id := range committed {
errs = append(errs, restoreCharacterRecord(u.character, id, original[id], c.exists[id], true))
}
if accountCommitted {
errs = append(errs, restoreAccountRecord(u.account, accountID, account, accountExists, true))
}
return errors.Join(errs...)
}
if a.dirty {
accountCommitted = true
if err := u.account.Save(ctx, a.record); err != nil {
return rollback(err)
}
}
for _, id := range ids {
if c.dirty[id] {
committed = append(committed, id)
if err := u.character.Save(ctx, c.records[id]); err != nil {
return rollback(err)
}
}
}
return nil
}
@@ -0,0 +1,132 @@
package memory
import (
"context"
"errors"
"sort"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
type memoryRaidEntryCostUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
inventory repository.InventoryRepository
}
func (u *memoryRaidEntryCostUnitOfWork) WithinRaidEntryCost(
ctx context.Context,
characterIDs []string,
apply func(repository.InventoryRepository) error,
) error {
ids, err := normalizeRaidEntryCostCharacterIDs(characterIDs)
if err != nil || u == nil || u.inventory == nil || apply == nil {
return repository.ErrRaidEntryCostTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
tx := &memoryRaidEntryCostInventory{
allowed: make(map[string]struct{}, len(ids)),
records: make(map[string]repository.InventoryRecord, len(ids)),
exists: make(map[string]bool, len(ids)),
dirty: make(map[string]bool, len(ids)),
}
preimages := make(map[string]repository.InventoryRecord, len(ids))
for _, id := range ids {
record, found, loadErr := u.inventory.Load(ctx, id)
if loadErr != nil {
return loadErr
}
tx.allowed[id] = struct{}{}
tx.records[id] = repository.CloneInventory(record)
tx.exists[id] = found
preimages[id] = repository.CloneInventory(record)
}
if err := apply(tx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
committed := make([]string, 0, len(ids))
for _, id := range ids {
if !tx.dirty[id] {
continue
}
if err := u.inventory.Save(ctx, repository.CloneInventory(tx.records[id])); err != nil {
rollback := []error{err}
for index := len(committed) - 1; index >= 0; index-- {
rollback = append(rollback, u.inventory.Save(context.Background(), preimages[committed[index]]))
}
return errors.Join(rollback...)
}
committed = append(committed, id)
}
return nil
}
type memoryRaidEntryCostInventory struct {
allowed map[string]struct{}
records map[string]repository.InventoryRecord
exists map[string]bool
dirty map[string]bool
}
func (tx *memoryRaidEntryCostInventory) Load(ctx context.Context, characterID string) (repository.InventoryRecord, bool, error) {
characterID = strings.TrimSpace(characterID)
if err := ctx.Err(); err != nil {
return repository.InventoryRecord{}, false, err
}
if _, ok := tx.allowed[characterID]; !ok {
return repository.InventoryRecord{}, false, repository.ErrRaidEntryCostTransactionUnavailable
}
return repository.CloneInventory(tx.records[characterID]), tx.exists[characterID], nil
}
func (tx *memoryRaidEntryCostInventory) Save(ctx context.Context, record repository.InventoryRecord) error {
characterID := strings.TrimSpace(record.CharacterID)
if err := ctx.Err(); err != nil {
return err
}
if _, ok := tx.allowed[characterID]; !ok {
return repository.ErrRaidEntryCostTransactionUnavailable
}
tx.records[characterID] = repository.CloneInventory(record)
tx.exists[characterID] = true
tx.dirty[characterID] = true
return nil
}
func normalizeRaidEntryCostCharacterIDs(source []string) ([]string, error) {
seen := make(map[string]struct{}, len(source))
ids := make([]string, 0, len(source))
for _, raw := range source {
id := strings.TrimSpace(raw)
if id == "" {
return nil, repository.ErrRaidEntryCostTransactionUnavailable
}
if _, duplicate := seen[id]; duplicate {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, repository.ErrRaidEntryCostTransactionUnavailable
}
sort.Strings(ids)
return ids, nil
}
@@ -0,0 +1,96 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
type memoryRaidRewardUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
inventory repository.InventoryRepository
settings repository.SettingsRepository
}
func (u *memoryRaidRewardUnitOfWork) WithinRaidRewards(
ctx context.Context,
characterIDs []string,
receiptScope string,
apply func(repository.InventoryRepository, repository.SettingsRepository) error,
) error {
ids, err := normalizeRaidEntryCostCharacterIDs(characterIDs)
receiptScope = strings.TrimSpace(receiptScope)
if err != nil || receiptScope == "" || u == nil || u.inventory == nil || u.settings == nil || apply == nil {
return repository.ErrRaidRewardTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
inventories := &memoryRaidEntryCostInventory{
allowed: make(map[string]struct{}, len(ids)),
records: make(map[string]repository.InventoryRecord, len(ids)),
exists: make(map[string]bool, len(ids)),
dirty: make(map[string]bool, len(ids)),
}
preimages := make(map[string]repository.InventoryRecord, len(ids))
for _, id := range ids {
record, found, loadErr := u.inventory.Load(ctx, id)
if loadErr != nil {
return loadErr
}
inventories.allowed[id] = struct{}{}
inventories.records[id] = repository.CloneInventory(record)
inventories.exists[id] = found
preimages[id] = repository.CloneInventory(record)
}
setting, settingFound, err := u.settings.Load(ctx, receiptScope)
if err != nil {
return err
}
receipt := &memorySettingsTransaction{
scope: receiptScope, record: repository.CloneSettings(setting), exists: settingFound,
}
if err := apply(inventories, receipt); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
committed := make([]string, 0, len(ids))
rollbackInventories := func() error {
var rollback []error
for index := len(committed) - 1; index >= 0; index-- {
rollback = append(rollback, u.inventory.Save(context.Background(), preimages[committed[index]]))
}
return errors.Join(rollback...)
}
for _, id := range ids {
if !inventories.dirty[id] {
continue
}
if err := u.inventory.Save(ctx, repository.CloneInventory(inventories.records[id])); err != nil {
return errors.Join(err, rollbackInventories())
}
committed = append(committed, id)
}
if receipt.dirty {
if err := u.settings.Save(ctx, repository.CloneSettings(receipt.record)); err != nil {
return errors.Join(err, rollbackInventories())
}
}
return nil
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,199 @@
package memory
import (
"context"
"errors"
"sort"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
type memoryAccountPairCharacterTradeUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
inventory repository.InventoryRepository
}
// WithinAccountPairCharacterTrade snapshots both warehouses and both
// characters, hands scoped repositories to apply, and restores every committed
// record when a later save fails. Passing the same account twice is valid: the
// two characters then share one warehouse and see one snapshot of it.
func (u *memoryAccountPairCharacterTradeUnitOfWork) WithinAccountPairCharacterTrade(
ctx context.Context,
firstAccountID string,
secondAccountID string,
firstCharacterID string,
secondCharacterID string,
apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository) error,
) error {
firstAccountID = strings.TrimSpace(firstAccountID)
secondAccountID = strings.TrimSpace(secondAccountID)
firstCharacterID = strings.TrimSpace(firstCharacterID)
secondCharacterID = strings.TrimSpace(secondCharacterID)
if firstAccountID == "" || secondAccountID == "" || firstCharacterID == "" || secondCharacterID == "" ||
firstCharacterID == secondCharacterID || apply == nil ||
u == nil || u.accountInventory == nil || u.character == nil || u.inventory == nil {
return repository.ErrAccountCharacterTradeTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
accountIDs := []string{firstAccountID}
if secondAccountID != firstAccountID {
accountIDs = append(accountIDs, secondAccountID)
}
sort.Strings(accountIDs)
characterIDs := []string{firstCharacterID, secondCharacterID}
sort.Strings(characterIDs)
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
accountRecords := make(map[string]repository.AccountInventoryRecord, len(accountIDs))
accountExists := make(map[string]bool, len(accountIDs))
for _, accountID := range accountIDs {
record, found, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
record = repository.CloneAccountInventory(record)
record.AccountID = accountID
if record.Slots == nil {
record.Slots = make(map[string]repository.ItemStack)
}
accountRecords[accountID] = record
accountExists[accountID] = found
}
characterRecords := make(map[string]repository.CharacterRecord, 2)
characterExists := make(map[string]bool, 2)
inventoryRecords := make(map[string]repository.InventoryRecord, 2)
inventoryExists := make(map[string]bool, 2)
for _, characterID := range characterIDs {
character, found, err := u.character.Load(ctx, characterID)
if err != nil {
return err
}
characterRecords[characterID] = repository.CloneCharacter(character)
characterExists[characterID] = found
inventory, found, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
inventoryRecords[characterID] = repository.CloneInventory(inventory)
inventoryExists[characterID] = found
}
allowedCharacters := map[string]struct{}{characterIDs[0]: {}, characterIDs[1]: {}}
allowedAccounts := make(map[string]struct{}, len(accountIDs))
for _, accountID := range accountIDs {
allowedAccounts[accountID] = struct{}{}
}
accountTx := &memoryAccountInventoryPairTransaction{
allowed: allowedAccounts,
records: accountRecords,
exists: accountExists,
dirty: make(map[string]bool, len(accountIDs)),
}
characterTx := &memoryCharacterTradeCharacterTransaction{allowed: allowedCharacters, records: characterRecords, exists: characterExists, dirty: make(map[string]bool, 2)}
inventoryTx := &memoryCharacterTradeInventoryTransaction{allowed: allowedCharacters, records: inventoryRecords, exists: inventoryExists, dirty: make(map[string]bool, 2)}
if err := apply(accountTx, characterTx, inventoryTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
type committedRecord struct {
kind string
id string
}
committed := make([]committedRecord, 0, 4)
rollback := func(cause error) error {
errs := []error{cause}
for index := len(committed) - 1; index >= 0; index-- {
row := committed[index]
switch row.kind {
case "account":
errs = append(errs, restoreAccountInventoryRecord(u.accountInventory, row.id, accountRecords[row.id], accountExists[row.id]))
case "character":
errs = append(errs, restoreCharacterRecord(u.character, row.id, characterRecords[row.id], characterExists[row.id], true))
case "inventory":
errs = append(errs, restoreInventoryRecord(u.inventory, row.id, inventoryRecords[row.id], inventoryExists[row.id]))
}
}
return errors.Join(errs...)
}
for _, accountID := range accountIDs {
if !accountTx.dirty[accountID] {
continue
}
if err := u.accountInventory.Save(ctx, accountTx.records[accountID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "account", id: accountID})
}
for _, characterID := range characterIDs {
if !characterTx.dirty[characterID] {
continue
}
if err := u.character.Save(ctx, characterTx.records[characterID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "character", id: characterID})
}
for _, characterID := range characterIDs {
if !inventoryTx.dirty[characterID] {
continue
}
if err := u.inventory.Save(ctx, inventoryTx.records[characterID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "inventory", id: characterID})
}
return nil
}
type memoryAccountInventoryPairTransaction struct {
allowed map[string]struct{}
records map[string]repository.AccountInventoryRecord
exists map[string]bool
dirty map[string]bool
}
func (tx *memoryAccountInventoryPairTransaction) Load(ctx context.Context, accountID string) (repository.AccountInventoryRecord, bool, error) {
accountID = strings.TrimSpace(accountID)
if _, ok := tx.allowed[accountID]; !ok {
return repository.AccountInventoryRecord{}, false, repository.ErrAccountCharacterTradeTransactionUnavailable
}
if err := ctx.Err(); err != nil {
return repository.AccountInventoryRecord{}, false, err
}
return repository.CloneAccountInventory(tx.records[accountID]), tx.exists[accountID], nil
}
func (tx *memoryAccountInventoryPairTransaction) Save(ctx context.Context, record repository.AccountInventoryRecord) error {
accountID := strings.TrimSpace(record.AccountID)
if _, ok := tx.allowed[accountID]; !ok {
return repository.ErrAccountCharacterTradeTransactionUnavailable
}
if err := ctx.Err(); err != nil {
return err
}
stored := repository.CloneAccountInventory(record)
stored.AccountID = accountID
tx.records[accountID] = stored
tx.exists[accountID] = true
tx.dirty[accountID] = true
return nil
}
@@ -0,0 +1,191 @@
package memory
import (
"context"
"errors"
"sort"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
type memoryAccountCharacterTradeUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
inventory repository.InventoryRepository
epicBook repository.EpicBookRepository
pets repository.PetRepository
}
func (u *memoryAccountCharacterTradeUnitOfWork) WithinAccountCharacterTrade(
ctx context.Context,
accountID string,
firstCharacterID string,
secondCharacterID string,
apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EpicBookRepository, repository.PetRepository) error,
) error {
accountID = strings.TrimSpace(accountID)
firstCharacterID = strings.TrimSpace(firstCharacterID)
secondCharacterID = strings.TrimSpace(secondCharacterID)
if accountID == "" || firstCharacterID == "" || secondCharacterID == "" || firstCharacterID == secondCharacterID || apply == nil ||
u == nil || u.accountInventory == nil || u.character == nil || u.inventory == nil || u.epicBook == nil || u.pets == nil {
return repository.ErrAccountCharacterTradeTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
ids := []string{firstCharacterID, secondCharacterID}
sort.Strings(ids)
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
account, accountExists, err := u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
account = repository.CloneAccountInventory(account)
account.AccountID = accountID
if account.Slots == nil {
account.Slots = make(map[string]repository.ItemStack)
}
epicBook, epicBookExists, err := u.epicBook.Load(ctx, accountID)
if err != nil {
return err
}
characterRecords := make(map[string]repository.CharacterRecord, 2)
characterExists := make(map[string]bool, 2)
inventoryRecords := make(map[string]repository.InventoryRecord, 2)
inventoryExists := make(map[string]bool, 2)
petRecords := make(map[string]repository.PetRecord, 2)
petExists := make(map[string]bool, 2)
for _, characterID := range ids {
character, found, loadErr := u.character.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
characterRecords[characterID] = repository.CloneCharacter(character)
characterExists[characterID] = found
inventory, found, loadErr := u.inventory.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
inventoryRecords[characterID] = repository.CloneInventory(inventory)
inventoryExists[characterID] = found
pet, found, loadErr := u.pets.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
petRecords[characterID] = repository.ClonePet(pet)
petExists[characterID] = found
}
allowed := map[string]struct{}{ids[0]: {}, ids[1]: {}}
accountTx := &memoryAccountInventoryTransaction{accountID: accountID, record: account, exists: accountExists}
characterTx := &memoryCharacterTradeCharacterTransaction{allowed: allowed, records: characterRecords, exists: characterExists, dirty: make(map[string]bool, 2)}
inventoryTx := &memoryCharacterTradeInventoryTransaction{allowed: allowed, records: inventoryRecords, exists: inventoryExists, dirty: make(map[string]bool, 2)}
epicBookTx := &memoryEpicBookTransaction{accountID: accountID, record: repository.CloneEpicBook(epicBook), exists: epicBookExists}
petTx := &memoryCharacterTradePetTransaction{allowed: allowed, records: petRecords, exists: petExists, dirty: make(map[string]bool, 2)}
if err := apply(accountTx, characterTx, inventoryTx, epicBookTx, petTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
type committedRecord struct {
kind string
id string
}
committed := make([]committedRecord, 0, 5)
rollback := func(cause error) error {
errs := []error{cause}
for index := len(committed) - 1; index >= 0; index-- {
row := committed[index]
switch row.kind {
case "account":
errs = append(errs, restoreAccountInventoryRecord(u.accountInventory, accountID, account, accountExists))
case "character":
errs = append(errs, restoreCharacterRecord(u.character, row.id, characterRecords[row.id], characterExists[row.id], true))
case "inventory":
errs = append(errs, restoreInventoryRecord(u.inventory, row.id, inventoryRecords[row.id], inventoryExists[row.id]))
case "epic_book":
errs = append(errs, restoreEpicBookRecord(u.epicBook, accountID, epicBook, epicBookExists))
case "pet":
errs = append(errs, restorePetRecordIfDirty(u.pets, row.id, petRecords[row.id], petExists[row.id], true))
}
}
return errors.Join(errs...)
}
if accountTx.dirty {
if err := u.accountInventory.Save(ctx, accountTx.record); err != nil {
return err
}
committed = append(committed, committedRecord{kind: "account", id: accountID})
}
if epicBookTx.dirty {
if err := u.epicBook.Save(ctx, epicBookTx.record); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "epic_book", id: accountID})
}
for _, characterID := range ids {
if characterTx.dirty[characterID] {
if err := u.character.Save(ctx, characterTx.records[characterID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "character", id: characterID})
}
}
for _, characterID := range ids {
if inventoryTx.dirty[characterID] {
if err := u.inventory.Save(ctx, inventoryTx.records[characterID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "inventory", id: characterID})
}
}
for _, characterID := range ids {
if petTx.dirty[characterID] {
if err := u.pets.Save(ctx, petTx.records[characterID]); err != nil {
return rollback(err)
}
committed = append(committed, committedRecord{kind: "pet", id: characterID})
}
}
return nil
}
type memoryCharacterTradePetTransaction struct {
allowed map[string]struct{}
records map[string]repository.PetRecord
exists map[string]bool
dirty map[string]bool
}
func (tx *memoryCharacterTradePetTransaction) Load(ctx context.Context, characterID string) (repository.PetRecord, bool, error) {
characterID = strings.TrimSpace(characterID)
if _, ok := tx.allowed[characterID]; !ok {
return repository.PetRecord{}, false, repository.ErrAccountCharacterTradeTransactionUnavailable
}
return repository.ClonePet(tx.records[characterID]), tx.exists[characterID], nil
}
func (tx *memoryCharacterTradePetTransaction) Save(ctx context.Context, record repository.PetRecord) error {
characterID := strings.TrimSpace(record.CharacterID)
if _, ok := tx.allowed[characterID]; !ok {
return repository.ErrAccountCharacterTradeTransactionUnavailable
}
tx.records[characterID] = repository.ClonePet(record)
tx.exists[characterID] = true
tx.dirty[characterID] = true
return nil
}
@@ -0,0 +1,282 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"strings"
"sync"
)
type memoryMailboxAssetUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
account repository.AccountRepository
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
inventory repository.InventoryRepository
mailbox repository.MailboxRepository
epicBook repository.EpicBookRepository
}
func (u *memoryMailboxAssetUnitOfWork) WithinMailboxAssets(
ctx context.Context,
assetCharacterID string,
mailboxCharacterID string,
apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.MailboxRepository) error,
) error {
if apply == nil {
return repository.ErrMailboxAssetTransactionUnavailable
}
return u.withinMailboxAssets(ctx, assetCharacterID, mailboxCharacterID, false, func(
accounts repository.AccountRepository,
accountInventories repository.AccountInventoryRepository,
characters repository.CharacterRepository,
inventories repository.InventoryRepository,
mailboxes repository.MailboxRepository,
_ repository.EpicBookRepository,
) error {
return apply(accounts, accountInventories, characters, inventories, mailboxes)
})
}
func (u *memoryMailboxAssetUnitOfWork) WithinMailboxClaimAssets(
ctx context.Context,
assetCharacterID string,
mailboxCharacterID string,
apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.MailboxRepository, repository.EpicBookRepository) error,
) error {
return u.withinMailboxAssets(ctx, assetCharacterID, mailboxCharacterID, true, apply)
}
func (u *memoryMailboxAssetUnitOfWork) withinMailboxAssets(
ctx context.Context,
assetCharacterID string,
mailboxCharacterID string,
includeEpicBook bool,
apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.MailboxRepository, repository.EpicBookRepository) error,
) error {
assetCharacterID = strings.TrimSpace(assetCharacterID)
mailboxCharacterID = strings.TrimSpace(mailboxCharacterID)
if assetCharacterID == "" || mailboxCharacterID == "" || apply == nil || u == nil ||
u.account == nil || u.accountInventory == nil || u.character == nil || u.inventory == nil || u.mailbox == nil ||
(includeEpicBook && u.epicBook == nil) {
return repository.ErrMailboxAssetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
character, characterExists, err := u.character.Load(ctx, assetCharacterID)
if err != nil {
return err
}
accountID := strings.TrimSpace(character.AccountID)
var account repository.AccountRecord
var accountExists bool
if accountID != "" {
account, accountExists, err = u.account.Load(ctx, accountID)
if err != nil {
return err
}
}
var accountInventory repository.AccountInventoryRecord
var accountInventoryExists bool
if accountID != "" {
accountInventory, accountInventoryExists, err = u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
}
var epicBook repository.EpicBookRecord
var epicBookExists bool
if includeEpicBook && accountID != "" {
epicBook, epicBookExists, err = u.epicBook.Load(ctx, accountID)
if err != nil {
return err
}
}
inventory, inventoryExists, err := u.inventory.Load(ctx, assetCharacterID)
if err != nil {
return err
}
mailbox, mailboxExists, err := u.mailbox.Load(ctx, mailboxCharacterID)
if err != nil {
return err
}
accountTx := &memoryAccountTransaction{
accountID: accountID,
record: repository.CloneAccount(account),
exists: accountExists,
}
accountInventoryTx := &memoryAccountInventoryTransaction{
accountID: accountID,
record: repository.CloneAccountInventory(accountInventory),
exists: accountInventoryExists,
}
characterTx := &memoryCharacterTransaction{
characterID: assetCharacterID,
record: repository.CloneCharacter(character),
exists: characterExists,
}
inventoryTx := &memoryInventoryTransaction{
characterID: assetCharacterID,
record: repository.CloneInventory(inventory),
exists: inventoryExists,
}
mailboxTx := &memoryMailboxTransaction{
characterID: mailboxCharacterID,
record: repository.CloneMailbox(mailbox),
exists: mailboxExists,
}
var epicBookTx *memoryEpicBookTransaction
if includeEpicBook {
epicBookTx = &memoryEpicBookTransaction{
accountID: accountID,
record: repository.CloneEpicBook(epicBook),
exists: epicBookExists,
}
}
if err := apply(accountTx, accountInventoryTx, characterTx, inventoryTx, mailboxTx, epicBookTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if accountTx.dirty {
if err := u.account.Save(ctx, accountTx.record); err != nil {
return errors.Join(err, restoreAccountRecord(u.account, accountID, account, accountExists, true))
}
}
if accountInventoryTx.dirty {
if err := u.accountInventory.Save(ctx, accountInventoryTx.record); err != nil {
return errors.Join(
err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountInventory, accountInventoryExists),
restoreAccountRecord(u.account, accountID, account, accountExists, accountTx.dirty),
)
}
}
if characterTx.dirty {
if err := u.character.Save(ctx, characterTx.record); err != nil {
return errors.Join(err, restoreCharacterRecord(
u.character,
assetCharacterID,
character,
characterExists,
true,
),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, account, accountExists, accountTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(
err,
restoreInventoryRecordIfDirty(u.inventory, assetCharacterID, inventory, inventoryExists, true),
restoreCharacterRecord(u.character, assetCharacterID, character, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, account, accountExists, accountTx.dirty),
)
}
}
if epicBookTx != nil && epicBookTx.dirty {
if err := u.epicBook.Save(ctx, epicBookTx.record); err != nil {
return errors.Join(
err,
restoreEpicBookRecord(u.epicBook, accountID, epicBook, epicBookExists),
restoreInventoryRecordIfDirty(u.inventory, assetCharacterID, inventory, inventoryExists, inventoryTx.dirty),
restoreCharacterRecord(u.character, assetCharacterID, character, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, account, accountExists, accountTx.dirty),
)
}
}
if mailboxTx.dirty {
if err := u.mailbox.Save(ctx, mailboxTx.record); err != nil {
return errors.Join(
err,
restoreMailboxRecordIfDirty(u.mailbox, mailboxCharacterID, mailbox, mailboxExists, true),
restoreEpicBookRecordIfDirty(u.epicBook, accountID, epicBook, epicBookExists, epicBookTx != nil && epicBookTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, assetCharacterID, inventory, inventoryExists, inventoryTx.dirty),
restoreCharacterRecord(u.character, assetCharacterID, character, characterExists, characterTx.dirty),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
restoreAccountRecord(u.account, accountID, account, accountExists, accountTx.dirty),
)
}
}
return nil
}
type memoryMailboxTransaction struct {
characterID string
record repository.MailboxRecord
exists bool
dirty bool
}
func (tx *memoryMailboxTransaction) Load(ctx context.Context, characterID string) (repository.MailboxRecord, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.characterID, characterID); err != nil {
return repository.MailboxRecord{}, false, err
}
return repository.CloneMailbox(tx.record), tx.exists, nil
}
func (tx *memoryMailboxTransaction) Save(ctx context.Context, record repository.MailboxRecord) error {
if err := repository.TransactionKeyError(ctx, tx.characterID, record.CharacterID); err != nil {
return err
}
tx.record = repository.CloneMailbox(record)
tx.exists = true
tx.dirty = true
return nil
}
func (tx *memoryMailboxTransaction) SaveFields(ctx context.Context, record repository.MailboxRecord, fields ...repository.MailboxField) error {
if err := repository.TransactionKeyError(ctx, tx.characterID, record.CharacterID); err != nil {
return err
}
if len(fields) == 0 {
return nil
}
tx.record = repository.CloneMailbox(record)
tx.exists = true
tx.dirty = true
return nil
}
func restoreMailboxRecordIfDirty(
repo repository.MailboxRepository,
characterID string,
record repository.MailboxRecord,
existed bool,
dirty bool,
) error {
if !dirty {
return nil
}
if existed {
return repo.Save(context.Background(), record)
}
deleter, ok := repo.(interface {
Delete(context.Context, string) error
})
if !ok {
return errors.New("mailbox rollback cannot remove newly created record")
}
return deleter.Delete(context.Background(), characterID)
}
@@ -0,0 +1,79 @@
package memory
import (
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestMemoryMailboxClaimAssetsRollsBackEpicBookAndMailboxTogether(t *testing.T) {
ctx := context.Background()
repositories := NewMemoryGroup()
if err := repositories.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "77",
AccountID: "acc-a",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Mailbox.Save(ctx, repository.MailboxRecord{
CharacterID: "77",
Mails: map[string]repository.MailRecord{
"1": {MailID: "1", Attachments: []repository.MailAttachment{{ItemID: 10300528, Count: 7}}},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.EpicBook.Save(ctx, repository.EpicBookRecord{
AccountID: "acc-a",
Balances: map[uint32]uint32{101020256: 10},
}); err != nil {
t.Fatal(err)
}
claimAssets, ok := repositories.MailboxAssets.(repository.MailboxClaimAssetUnitOfWork)
if !ok {
t.Fatal("memory mailbox transaction lacks Epic Book claim capability")
}
wantErr := errors.New("reject staged mail claim")
err := claimAssets.WithinMailboxClaimAssets(ctx, "77", "77", func(
_ repository.AccountRepository,
_ repository.AccountInventoryRepository,
_ repository.CharacterRepository,
_ repository.InventoryRepository,
mailboxes repository.MailboxRepository,
epicBooks repository.EpicBookRepository,
) error {
book, _, loadErr := epicBooks.Load(ctx, "acc-a")
if loadErr != nil {
return loadErr
}
book.Balances[101020256] = 17
if saveErr := epicBooks.Save(ctx, book); saveErr != nil {
return saveErr
}
mailbox, _, loadErr := mailboxes.Load(ctx, "77")
if loadErr != nil {
return loadErr
}
mail := mailbox.Mails["1"]
mail.Claimed = true
mail.Attachments = nil
mailbox.Mails["1"] = mail
if saveErr := mailboxes.Save(ctx, mailbox); saveErr != nil {
return saveErr
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinMailboxClaimAssets error=%v, want=%v", err, wantErr)
}
book, found, err := repositories.EpicBook.Load(ctx, "acc-a")
if err != nil || !found || book.Balances[101020256] != 10 {
t.Fatalf("rolled-back Epic Book found=%v err=%v record=%+v", found, err, book)
}
mailbox, found, err := repositories.Mailbox.Load(ctx, "77")
if err != nil || !found || mailbox.Mails["1"].Claimed || len(mailbox.Mails["1"].Attachments) != 1 {
t.Fatalf("rolled-back mailbox found=%v err=%v record=%+v", found, err, mailbox)
}
}
@@ -0,0 +1,149 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"strings"
"sync"
)
// memoryCharacterPetUnitOfWork buffers all three pet-owned aggregates and
// commits them while holding the same mutex used by the other character item
// transactions. This prevents hatch/equip/feed operations from racing a
// concurrent inventory or equipment mutation in memory-backed tests.
type memoryCharacterPetUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
inventory repository.InventoryRepository
equipment repository.EquipmentRepository
pets repository.PetRepository
}
func (u *memoryCharacterPetUnitOfWork) WithinCharacterPets(
ctx context.Context,
characterID string,
apply func(repository.InventoryRepository, repository.EquipmentRepository, repository.PetRepository) error,
) error {
characterID = strings.TrimSpace(characterID)
if characterID == "" || apply == nil || u == nil || u.inventory == nil || u.equipment == nil || u.pets == nil {
return repository.ErrCharacterPetTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
inventory, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipment, equipmentExists, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
pet, petExists, err := u.pets.Load(ctx, characterID)
if err != nil {
return err
}
inventoryTx := &memoryInventoryTransaction{
characterID: characterID,
record: repository.CloneInventory(inventory),
exists: inventoryExists,
}
equipmentTx := &memoryEquipmentTransaction{
characterID: characterID,
record: repository.CloneEquipment(equipment),
exists: equipmentExists,
}
petTx := &memoryPetTransaction{
characterID: characterID,
record: repository.ClonePet(pet),
exists: petExists,
}
if err := apply(inventoryTx, equipmentTx, petTx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(
err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, true),
)
}
}
if equipmentTx.dirty {
if err := u.equipment.Save(ctx, equipmentTx.record); err != nil {
return errors.Join(
err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, true),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
)
}
}
if petTx.dirty {
if err := u.pets.Save(ctx, petTx.record); err != nil {
return errors.Join(
err,
restorePetRecordIfDirty(u.pets, characterID, pet, petExists, true),
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
)
}
}
return nil
}
type memoryPetTransaction struct {
characterID string
record repository.PetRecord
exists bool
dirty bool
}
func (tx *memoryPetTransaction) Load(ctx context.Context, characterID string) (repository.PetRecord, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.characterID, characterID); err != nil {
return repository.PetRecord{}, false, err
}
return repository.ClonePet(tx.record), tx.exists, nil
}
func (tx *memoryPetTransaction) Save(ctx context.Context, record repository.PetRecord) error {
if err := repository.TransactionKeyError(ctx, tx.characterID, record.CharacterID); err != nil {
return err
}
tx.record = repository.ClonePet(record)
tx.exists = true
tx.dirty = true
return nil
}
func restorePetRecordIfDirty(repo repository.PetRepository, characterID string, record repository.PetRecord, existed bool, dirty bool) error {
if !dirty {
return nil
}
if existed {
return repo.Save(context.Background(), record)
}
deleter, ok := repo.(interface {
Delete(context.Context, string) error
})
if !ok {
return errors.New("pet rollback cannot remove newly created record")
}
return deleter.Delete(context.Background(), characterID)
}
@@ -0,0 +1,511 @@
package memory
import (
"context"
"errors"
"sort"
"strings"
"sync"
"longheng.io/server/internal/modules/dnf/repository"
)
// memoryCharacterSettlementUnitOfWork buffers all settlement aggregates
// before committing them under the shared aggregate transaction mutex.
type memoryCharacterSettlementUnitOfWork struct {
mu sync.Mutex
sharedMu *sync.Mutex
account repository.AccountRepository
accountInventory repository.AccountInventoryRepository
character repository.CharacterRepository
quests repository.QuestRepository
inventory repository.InventoryRepository
equipment repository.EquipmentRepository
skills repository.SkillRepository
settings repository.SettingsRepository
hellPartyPermissions repository.HellPartyPermissionRepository
}
func (u *memoryCharacterSettlementUnitOfWork) WithinCharacterSettlement(
ctx context.Context,
characterID string,
apply func(repository.Group) error,
) (err error) {
characterID = strings.TrimSpace(characterID)
if characterID == "" || apply == nil || u == nil || u.character == nil ||
u.quests == nil || u.inventory == nil || u.equipment == nil || u.skills == nil {
return repository.ErrCharacterSettlementTransactionUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
lock := &u.mu
if u.sharedMu != nil {
lock = u.sharedMu
}
lock.Lock()
defer lock.Unlock()
var hellPartyPermissionsTx *memorySettlementHellPartyPermissionTransaction
var permissionsCommitted bool
defer func() {
if err == nil || !permissionsCommitted || hellPartyPermissionsTx == nil {
return
}
if rollbackErr := hellPartyPermissionsTx.rollback(ctx); rollbackErr != nil {
err = errors.Join(err, rollbackErr)
}
}()
character, characterExists, err := u.character.Load(ctx, characterID)
if err != nil {
return err
}
accountID := strings.TrimSpace(character.AccountID)
var account repository.AccountRecord
var accountExists bool
if accountID != "" && u.account != nil {
account, accountExists, err = u.account.Load(ctx, accountID)
if err != nil {
return err
}
}
var accountInventory repository.AccountInventoryRecord
var accountInventoryExists bool
if accountID != "" && u.accountInventory != nil {
accountInventory, accountInventoryExists, err = u.accountInventory.Load(ctx, accountID)
if err != nil {
return err
}
}
questRecord, questExists, err := u.quests.Load(ctx, characterID)
if err != nil {
return err
}
inventory, inventoryExists, err := u.inventory.Load(ctx, characterID)
if err != nil {
return err
}
equipment, equipmentExists, err := u.equipment.Load(ctx, characterID)
if err != nil {
return err
}
skills, skillExists, err := u.skills.Load(ctx, characterID)
if err != nil {
return err
}
if u.hellPartyPermissions != nil {
hellPartyPermissionsTx = &memorySettlementHellPartyPermissionTransaction{base: u.hellPartyPermissions, characterID: characterID}
}
settingsTx := &memorySettlementSettingsTransaction{
base: u.settings,
records: make(map[string]repository.SettingsRecord),
exists: make(map[string]bool),
original: make(map[string]repository.SettingsRecord),
originalExists: make(map[string]bool),
dirty: make(map[string]bool),
}
characterTx := &memoryCharacterTransaction{
characterID: characterID,
record: repository.CloneCharacter(character),
exists: characterExists,
}
questTx := &memoryQuestTransaction{
characterID: characterID,
record: repository.CloneQuest(questRecord),
exists: questExists,
}
inventoryTx := &memoryInventoryTransaction{
characterID: characterID,
record: repository.CloneInventory(inventory),
exists: inventoryExists,
}
equipmentTx := &memoryEquipmentTransaction{
characterID: characterID,
record: repository.CloneEquipment(equipment),
exists: equipmentExists,
}
skillTx := &memorySkillTransaction{
characterID: characterID,
record: repository.CloneSkill(skills),
exists: skillExists,
}
accountInventoryTx := &memoryAccountInventoryTransaction{
accountID: accountID,
record: repository.CloneAccountInventory(accountInventory),
exists: accountInventoryExists,
}
var accountTx *memoryAccountTransaction
if u.account != nil {
accountTx = &memoryAccountTransaction{
accountID: accountID,
record: repository.CloneAccount(account),
exists: accountExists,
}
}
txGroup := repository.Group{
Account: accountTx,
AccountInventory: accountInventoryTx,
Character: characterTx,
Quest: questTx,
Inventory: inventoryTx,
Equipment: equipmentTx,
Skill: skillTx,
Settings: settingsTx,
}
if hellPartyPermissionsTx != nil {
txGroup.HellPartyPermission = hellPartyPermissionsTx
}
if err := apply(txGroup); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if characterTx.dirty {
if err := u.character.Save(ctx, characterTx.record); err != nil {
return errors.Join(
err,
restoreCharacterRecord(u.character, characterID, character, characterExists, true),
)
}
}
if questTx.dirty {
if err := u.quests.Save(ctx, questTx.record); err != nil {
return errors.Join(
err,
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, true),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if skillTx.dirty {
if err := u.skills.Save(ctx, skillTx.record); err != nil {
return errors.Join(
err,
restoreSkillRecord(u.skills, characterID, skills, skillExists, true),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if inventoryTx.dirty {
if err := u.inventory.Save(ctx, inventoryTx.record); err != nil {
return errors.Join(
err,
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, true),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if equipmentTx.dirty {
if err := u.equipment.Save(ctx, equipmentTx.record); err != nil {
return errors.Join(
err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, true),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if settingsTx.dirtyCount() > 0 {
if err := settingsTx.commit(ctx); err != nil {
return errors.Join(
err,
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if accountInventoryTx.dirty {
if u.accountInventory == nil {
return errors.Join(
repository.ErrCharacterSettlementTransactionUnavailable,
settingsTx.rollback(),
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
if err := u.accountInventory.Save(ctx, accountInventoryTx.record); err != nil {
return errors.Join(
err,
restoreAccountInventoryRecord(u.accountInventory, accountID, accountInventory, accountInventoryExists),
settingsTx.rollback(),
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if accountTx != nil && accountTx.dirty {
if err := u.account.Save(ctx, accountTx.record); err != nil {
return errors.Join(
err,
restoreAccountRecord(u.account, accountID, account, accountExists, true),
restoreAccountInventoryRecordIfDirty(u.accountInventory, accountID, accountInventory, accountInventoryExists, accountInventoryTx.dirty),
settingsTx.rollback(),
restoreEquipmentRecordIfDirty(u.equipment, characterID, equipment, equipmentExists, equipmentTx.dirty),
restoreInventoryRecordIfDirty(u.inventory, characterID, inventory, inventoryExists, inventoryTx.dirty),
restoreSkillRecord(u.skills, characterID, skills, skillExists, skillTx.dirty),
restoreQuestRecordIfDirty(u.quests, characterID, questRecord, questExists, questTx.dirty),
restoreCharacterRecord(u.character, characterID, character, characterExists, characterTx.dirty),
)
}
}
if hellPartyPermissionsTx != nil {
if err := hellPartyPermissionsTx.commit(ctx); err != nil {
return err
}
permissionsCommitted = true
}
return nil
}
type memorySettlementHellPartyPermissionTransaction struct {
base repository.HellPartyPermissionRepository
characterID string
pending []repository.HellPartyPermissionEntry
}
func (tx *memorySettlementHellPartyPermissionTransaction) Load(ctx context.Context, characterID string) (repository.HellPartyPermissionRecord, bool, error) {
if tx == nil || tx.base == nil {
return repository.HellPartyPermissionRecord{}, false, repository.ErrCharacterSettlementTransactionUnavailable
}
if err := repository.TransactionKeyError(ctx, tx.characterID, characterID); err != nil {
return repository.HellPartyPermissionRecord{}, false, err
}
record, found, err := tx.base.Load(ctx, characterID)
if err != nil {
return repository.HellPartyPermissionRecord{}, false, err
}
for _, pending := range tx.pending {
seen := false
for _, entry := range record.Entries {
if entry.AreaID == pending.AreaID {
seen = true
break
}
}
if !seen {
record.Entries = append(record.Entries, pending)
found = true
}
}
return repository.CloneHellPartyPermission(record), found, nil
}
func (tx *memorySettlementHellPartyPermissionTransaction) Upsert(ctx context.Context, characterID string, areaID, questID int64) (repository.HellPartyPermissionEntry, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.characterID, characterID); err != nil {
return repository.HellPartyPermissionEntry{}, false, err
}
if areaID <= 0 || questID <= 0 {
return repository.HellPartyPermissionEntry{}, false, nil
}
record, _, err := tx.Load(ctx, characterID)
if err != nil {
return repository.HellPartyPermissionEntry{}, false, err
}
for _, entry := range record.Entries {
if entry.AreaID == areaID {
return entry, false, nil
}
}
entry := repository.HellPartyPermissionEntry{AreaID: areaID, QuestID: questID}
tx.pending = append(tx.pending, entry)
return entry, true, nil
}
func (tx *memorySettlementHellPartyPermissionTransaction) commit(ctx context.Context) error {
if tx == nil || tx.base == nil {
return repository.ErrCharacterSettlementTransactionUnavailable
}
inserted := make([]repository.HellPartyPermissionEntry, 0, len(tx.pending))
for _, entry := range tx.pending {
if _, changed, err := tx.base.Upsert(ctx, tx.characterID, entry.AreaID, entry.QuestID); err != nil {
for _, prior := range inserted {
_ = tx.delete(ctx, prior.AreaID)
}
return err
} else if changed {
inserted = append(inserted, entry)
}
}
return nil
}
func (tx *memorySettlementHellPartyPermissionTransaction) rollback(ctx context.Context) error {
if tx == nil || tx.base == nil {
return nil
}
var joined error
for _, entry := range tx.pending {
if err := tx.delete(ctx, entry.AreaID); err != nil {
joined = errors.Join(joined, err)
}
}
return joined
}
func (tx *memorySettlementHellPartyPermissionTransaction) delete(ctx context.Context, areaID int64) error {
restorer, ok := tx.base.(repository.HellPartyPermissionRestorer)
if !ok {
return repository.ErrCharacterSettlementTransactionUnavailable
}
return restorer.Delete(ctx, tx.characterID, areaID)
}
type memorySettlementSettingsTransaction struct {
base repository.SettingsRepository
records map[string]repository.SettingsRecord
exists map[string]bool
original map[string]repository.SettingsRecord
originalExists map[string]bool
dirty map[string]bool
committed []string
}
func (tx *memorySettlementSettingsTransaction) Load(ctx context.Context, scope string) (repository.SettingsRecord, bool, error) {
if tx == nil || tx.base == nil {
return repository.SettingsRecord{}, false, repository.ErrCharacterSettlementTransactionUnavailable
}
if record, cached := tx.records[scope]; cached || tx.exists[scope] {
return repository.CloneSettings(record), tx.exists[scope], nil
}
record, found, err := tx.base.Load(ctx, scope)
if err != nil {
return repository.SettingsRecord{}, false, err
}
tx.records[scope] = repository.CloneSettings(record)
tx.exists[scope] = found
tx.original[scope] = repository.CloneSettings(record)
tx.originalExists[scope] = found
return repository.CloneSettings(record), found, nil
}
func (tx *memorySettlementSettingsTransaction) Save(ctx context.Context, record repository.SettingsRecord) error {
if tx == nil || tx.base == nil {
return repository.ErrCharacterSettlementTransactionUnavailable
}
scope := repository.SettingsKey(record)
if scope == "" {
return repository.ErrCharacterSettlementTransactionUnavailable
}
if _, captured := tx.original[scope]; !captured {
original, found, err := tx.base.Load(ctx, scope)
if err != nil {
return err
}
tx.original[scope] = repository.CloneSettings(original)
tx.originalExists[scope] = found
}
tx.records[scope] = repository.CloneSettings(record)
tx.exists[scope] = true
tx.dirty[scope] = true
return nil
}
func (tx *memorySettlementSettingsTransaction) dirtyCount() int {
if tx == nil {
return 0
}
return len(tx.dirty)
}
func (tx *memorySettlementSettingsTransaction) commit(ctx context.Context) error {
scopes := make([]string, 0, len(tx.dirty))
for scope := range tx.dirty {
scopes = append(scopes, scope)
}
sort.Strings(scopes)
for _, scope := range scopes {
// A store is allowed to mutate and then report an error. Register the
// scope before Save so the pre-image is restored in either outcome.
tx.committed = append(tx.committed, scope)
if err := tx.base.Save(ctx, tx.records[scope]); err != nil {
return errors.Join(err, tx.rollback())
}
}
return nil
}
func (tx *memorySettlementSettingsTransaction) rollback() error {
if tx == nil || tx.base == nil {
return nil
}
var rollbackErr error
for index := len(tx.committed) - 1; index >= 0; index-- {
scope := tx.committed[index]
rollbackErr = errors.Join(rollbackErr, restoreSettingsRecord(
tx.base,
scope,
tx.original[scope],
tx.originalExists[scope],
true,
))
}
tx.committed = nil
return rollbackErr
}
type memoryQuestTransaction struct {
characterID string
record repository.QuestRecord
exists bool
dirty bool
}
func (tx *memoryQuestTransaction) Load(ctx context.Context, characterID string) (repository.QuestRecord, bool, error) {
if err := repository.TransactionKeyError(ctx, tx.characterID, characterID); err != nil {
return repository.QuestRecord{}, false, err
}
return repository.CloneQuest(tx.record), tx.exists, nil
}
func (tx *memoryQuestTransaction) Save(ctx context.Context, record repository.QuestRecord) error {
if err := repository.TransactionKeyError(ctx, tx.characterID, record.CharacterID); err != nil {
return err
}
tx.record = repository.CloneQuest(record)
tx.exists = true
tx.dirty = true
return nil
}
func restoreQuestRecordIfDirty(
repo repository.QuestRepository,
characterID string,
record repository.QuestRecord,
existed bool,
dirty bool,
) error {
if !dirty {
return nil
}
if existed {
return repo.Save(context.Background(), record)
}
deleter, ok := repo.(interface {
Delete(context.Context, string) error
})
if !ok {
return errors.New("quest rollback cannot remove newly created record")
}
return deleter.Delete(context.Background(), characterID)
}
@@ -0,0 +1,38 @@
package memory
import (
"context"
"sort"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/platform/db"
)
type memoryUnitedFriendStore struct {
*db.MemoryStore[repository.UnitedFriendRecord]
}
func newMemoryUnitedFriendStore() *memoryUnitedFriendStore {
return &memoryUnitedFriendStore{MemoryStore: db.NewMemoryStore(repository.UnitedFriendKey, repository.CloneUnitedFriend)}
}
func (s *memoryUnitedFriendStore) Save(ctx context.Context, record repository.UnitedFriendRecord) error {
return s.MemoryStore.Save(ctx, repository.NormalizeUnitedFriend(record))
}
func (s *memoryUnitedFriendStore) ListObservers(ctx context.Context, friendName string) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var out []string
for _, record := range s.Snapshot(nil) {
for _, name := range record.FriendNames {
if name == friendName {
out = append(out, record.OwnerName)
break
}
}
}
sort.Strings(out)
return out, nil
}
@@ -0,0 +1,324 @@
package memory
import (
"context"
"encoding/json"
"longheng.io/server/internal/modules/dnf/repository"
"sort"
"strconv"
"strings"
"testing"
"github.com/gomodule/redigo/redis"
"longheng.io/server/internal/platform/db"
)
func TestRedisCachedCharacterRepositoryCachesListAndName(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
executor := newFakeRedisExecutor()
repo := repository.NewRedisCachedCharacterRepository(backing, executor, repository.RedisCacheOptions{KeyPrefix: "test:dnf"})
record := repository.CharacterRecord{
CharacterID: "1",
AccountID: "dnf:1",
Slot: 2,
Name: "hero",
Job: "15",
Level: 48,
Stats: map[string]int64{"grow_type": 1},
}
if err := repo.Save(ctx, record); err != nil {
t.Fatalf("Save() error = %v", err)
}
cached := repo.(*repository.RedisCachedCharStore)
if got, err := redis.String(executor.Do(ctx, "GET", cached.NameKey("hero"))); err != nil || got != "1" {
t.Fatalf("name cache = %q err=%v, want 1", got, err)
}
if err := backing.Save(ctx, repository.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Slot: 2, Name: "mutated"}); err != nil {
t.Fatalf("mutate backing: %v", err)
}
loaded, ok, err := repo.Load(ctx, "1")
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
if loaded.Name != "mutated" {
t.Fatalf("Load() did not use MySQL-authoritative record: %+v", loaded)
}
list, err := repo.ListByAccount(ctx, "dnf:1", 8)
if err != nil {
t.Fatalf("ListByAccount() error = %v", err)
}
if len(list) != 1 || list[0].CharacterID != "1" || list[0].Name != "mutated" {
t.Fatalf("validated list = %+v, want MySQL-authoritative mutated record", list)
}
}
func TestRedisCachedCharacterRepositoryRepairsIncompleteAccountList(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
executor := newFakeRedisExecutor()
repo := repository.NewRedisCachedCharacterRepository(backing, executor, repository.RedisCacheOptions{KeyPrefix: "test:dnf"})
first := repository.CharacterRecord{
CharacterID: "1",
AccountID: "dnf:1",
Slot: 0,
Name: "first",
Job: "15",
Level: 1,
}
if err := repo.Save(ctx, first); err != nil {
t.Fatalf("Save(first) error = %v", err)
}
if err := backing.Save(ctx, repository.CharacterRecord{
CharacterID: "2",
AccountID: "dnf:1",
Slot: 1,
Name: "second",
Job: "11",
Level: 1,
}); err != nil {
t.Fatalf("seed backing second: %v", err)
}
list, err := repo.ListByAccount(ctx, "dnf:1", 8)
if err != nil {
t.Fatalf("ListByAccount() error = %v", err)
}
if len(list) != 2 || list[0].CharacterID != "1" || list[1].CharacterID != "2" {
t.Fatalf("repaired list = %+v, want both MySQL-backed records", list)
}
cached := repo.(*repository.RedisCachedCharStore)
if ids, err := redis.Strings(executor.Do(ctx, "ZRANGE", cached.AccountKey("dnf:1"), 0, 7)); err != nil || strings.Join(ids, ",") != "1,2" {
t.Fatalf("repaired redis index ids=%v err=%v, want 1,2", ids, err)
}
}
func TestRedisCachedCharacterRepositoryRepairsStaleExtraAccountList(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
executor := newFakeRedisExecutor()
repo := repository.NewRedisCachedCharacterRepository(backing, executor, repository.RedisCacheOptions{KeyPrefix: "test:dnf"})
first := repository.CharacterRecord{
CharacterID: "1",
AccountID: "dnf:1",
Slot: 0,
Name: "first",
Job: "15",
Level: 1,
}
if err := repo.Save(ctx, first); err != nil {
t.Fatalf("Save(first) error = %v", err)
}
cached := repo.(*repository.RedisCachedCharStore)
if err := cached.CacheRecord(ctx, repository.CharacterRecord{
CharacterID: "2",
AccountID: "dnf:1",
Slot: 1,
Name: "stale",
Job: "11",
Level: 1,
}); err != nil {
t.Fatalf("seed stale redis record: %v", err)
}
list, err := repo.ListByAccount(ctx, "dnf:1", 8)
if err != nil {
t.Fatalf("ListByAccount() error = %v", err)
}
if len(list) != 1 || list[0].CharacterID != "1" {
t.Fatalf("repaired list = %+v, want only MySQL-backed record 1", list)
}
if ids, err := redis.Strings(executor.Do(ctx, "ZRANGE", cached.AccountKey("dnf:1"), 0, 7)); err != nil || strings.Join(ids, ",") != "1" {
t.Fatalf("repaired redis index ids=%v err=%v, want 1", ids, err)
}
}
func TestRedisCachedCharacterSaveFieldsBackfillsCache(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
executor := newFakeRedisExecutor()
repo := repository.NewRedisCachedCharacterRepository(backing, executor, repository.RedisCacheOptions{KeyPrefix: "test:dnf"})
record := repository.CharacterRecord{
CharacterID: "7",
AccountID: "dnf:1",
Slot: 0,
Name: "field",
Stats: map[string]int64{"str": 10},
}
if err := repo.Save(ctx, record); err != nil {
t.Fatalf("Save() error = %v", err)
}
record.Stats["str"] = 99
if err := repository.SaveCharacterFields(ctx, repo, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
loaded, ok, err := repo.Load(ctx, "7")
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
if loaded.Stats["str"] != 99 {
t.Fatalf("cached stats = %+v, want str=99", loaded.Stats)
}
}
func TestRedisCachedCharacterNextNumericIDSeedsFromMySQL(t *testing.T) {
ctx := context.Background()
backing := newMemoryCharStore()
if err := backing.Save(ctx, repository.CharacterRecord{CharacterID: "41", AccountID: "dnf:1", Name: "old"}); err != nil {
t.Fatalf("seed backing: %v", err)
}
repo := repository.NewRedisCachedCharacterRepository(backing, newFakeRedisExecutor(), repository.RedisCacheOptions{KeyPrefix: "test:dnf"})
first, err := repo.NextNumericID(ctx)
if err != nil {
t.Fatalf("NextNumericID() error = %v", err)
}
second, err := repo.NextNumericID(ctx)
if err != nil {
t.Fatalf("NextNumericID() second error = %v", err)
}
if first != 42 || second != 43 {
t.Fatalf("ids = %d,%d want 42,43", first, second)
}
}
type fakeRedisExecutor struct {
values map[string]any
zsets map[string]map[string]float64
}
func newFakeRedisExecutor() *fakeRedisExecutor {
return &fakeRedisExecutor{
values: make(map[string]any),
zsets: make(map[string]map[string]float64),
}
}
func (e *fakeRedisExecutor) Do(_ context.Context, command string, args ...any) (any, error) {
switch strings.ToUpper(command) {
case "PING":
return "PONG", nil
case "GET":
value, ok := e.values[argString(args, 0)]
if !ok {
return nil, redis.ErrNil
}
return value, nil
case "SET":
e.values[argString(args, 0)] = cloneRedisValue(args[1])
return "OK", nil
case "SETEX":
e.values[argString(args, 0)] = cloneRedisValue(args[2])
return "OK", nil
case "DEL":
for _, arg := range args {
key := toString(arg)
delete(e.values, key)
delete(e.zsets, key)
}
return len(args), nil
case "ZADD":
key := argString(args, 0)
score, _ := strconv.ParseFloat(toString(args[1]), 64)
member := toString(args[2])
if e.zsets[key] == nil {
e.zsets[key] = make(map[string]float64)
}
e.zsets[key][member] = score
return 1, nil
case "ZRANGE":
key := argString(args, 0)
start, _ := strconv.Atoi(toString(args[1]))
stop, _ := strconv.Atoi(toString(args[2]))
return e.zrange(key, start, stop), nil
case "EXPIRE":
return 1, nil
case "EVAL":
key := argString(args, 2)
seed, _ := strconv.Atoi(toString(args[3]))
current, _ := strconv.Atoi(toString(e.values[key]))
if current < seed {
current = seed
}
current++
e.values[key] = strconv.Itoa(current)
return int64(current), nil
default:
return nil, redis.Error("unsupported command " + command)
}
}
func (e *fakeRedisExecutor) DoBatch(ctx context.Context, commands []db.RedisCommand) error {
for _, command := range commands {
if _, err := e.Do(ctx, command.Name, command.Args...); err != nil {
return err
}
}
return nil
}
func (e *fakeRedisExecutor) zrange(key string, start, stop int) []any {
type entry struct {
member string
score float64
}
entries := make([]entry, 0, len(e.zsets[key]))
for member, score := range e.zsets[key] {
entries = append(entries, entry{member: member, score: score})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].score == entries[j].score {
return entries[i].member < entries[j].member
}
return entries[i].score < entries[j].score
})
if start < 0 {
start = 0
}
if stop >= len(entries) {
stop = len(entries) - 1
}
if start > stop || start >= len(entries) {
return nil
}
out := make([]any, 0, stop-start+1)
for _, item := range entries[start : stop+1] {
out = append(out, []byte(item.member))
}
return out
}
func argString(args []any, idx int) string {
if idx >= len(args) {
return ""
}
return toString(args[idx])
}
func toString(value any) string {
switch typed := value.(type) {
case string:
return typed
case []byte:
return string(typed)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case nil:
return ""
default:
data, _ := json.Marshal(typed)
return string(data)
}
}
func cloneRedisValue(value any) any {
if data, ok := value.([]byte); ok {
return append([]byte(nil), data...)
}
return value
}
@@ -0,0 +1,198 @@
package memory
import (
"context"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"sync"
"testing"
)
func TestMemoryCharacterAssetUnitOfWorkCommitsWalletAndInventory(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterAssets(t, ctx, repos)
err := repos.CharacterAssets.WithinCharacterAssets(ctx, "77", func(characters repository.CharacterRepository, inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
character, ok, err := characters.Load(ctx, "77")
if err != nil || !ok {
return errors.New("character missing in transaction")
}
bag, ok, err := inventory.Load(ctx, "77")
if err != nil || !ok {
return errors.New("inventory missing in transaction")
}
character.Stats["gold"] += 125
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 2}
if err := repository.SaveCharacterFields(ctx, characters, character, repository.CharacterFieldStats); err != nil {
return err
}
return repository.SaveInventoryFields(ctx, inventory, bag, repository.InventoryFieldSlots)
})
if err != nil {
t.Fatalf("WithinCharacterAssets error = %v", err)
}
character, _, _ := repos.Character.Load(ctx, "77")
bag, _, _ := repos.Inventory.Load(ctx, "77")
if got := character.Stats["gold"]; got != 225 {
t.Fatalf("gold = %d, want 225", got)
}
if got := bag.Slots["0:1"]; got.ItemID != 9001 || got.Count != 2 {
t.Fatalf("reward stack = %+v", got)
}
}
func TestMemoryCharacterAssetUnitOfWorkRollsBackCallback(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterAssets(t, ctx, repos)
wantErr := errors.New("reject grant")
err := repos.CharacterAssets.WithinCharacterAssets(ctx, "77", func(characters repository.CharacterRepository, inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
character, _, _ := characters.Load(ctx, "77")
bag, _, _ := inventory.Load(ctx, "77")
character.Stats["gold"] = 999
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 1}
if err := characters.Save(ctx, character); err != nil {
return err
}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinCharacterAssets error = %v, want %v", err, wantErr)
}
assertOriginalCharacterAssets(t, ctx, repos)
}
func TestMemoryCharacterAssetUnitOfWorkRestoresEarlierCommits(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterAssets(t, ctx, repos)
commitErr := errors.New("equipment commit failed")
failingEquipment := &mutatingEquipmentStore{EquipmentRepository: repos.Equipment, saveErr: commitErr}
repos.CharacterAssets = &memoryCharacterAssetUnitOfWork{
character: repos.Character,
inventory: repos.Inventory,
equipment: failingEquipment,
}
err := repos.CharacterAssets.WithinCharacterAssets(ctx, "77", func(characters repository.CharacterRepository, inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
character, _, _ := characters.Load(ctx, "77")
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
character.Stats["gold"] = 999
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 1}
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 8001}
if err := characters.Save(ctx, character); err != nil {
return err
}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return equipment.Save(ctx, worn)
})
if !errors.Is(err, commitErr) {
t.Fatalf("WithinCharacterAssets error = %v, want %v", err, commitErr)
}
assertOriginalCharacterAssets(t, ctx, repos)
}
type mutatingEquipmentStore struct {
repository.EquipmentRepository
saveErr error
failed bool
}
func (s *mutatingEquipmentStore) Save(ctx context.Context, record repository.EquipmentRecord) error {
if !s.failed {
s.failed = true
if err := s.EquipmentRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.EquipmentRepository.Save(ctx, record)
}
func TestMemoryCharacterAssetUnitOfWorkSerializesWalletUpdates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterAssets(t, ctx, repos)
var wg sync.WaitGroup
errs := make(chan error, 40)
for range 40 {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repos.CharacterAssets.WithinCharacterAssets(ctx, "77", func(characters repository.CharacterRepository, _ repository.InventoryRepository, _ repository.EquipmentRepository) error {
character, ok, err := characters.Load(ctx, "77")
if err != nil || !ok {
return errors.New("character missing")
}
character.Stats["gold"]++
return characters.Save(ctx, character)
})
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent update: %v", err)
}
}
character, _, _ := repos.Character.Load(ctx, "77")
if got := character.Stats["gold"]; got != 140 {
t.Fatalf("gold = %d, want 140", got)
}
}
func seedCharacterAssets(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.CharacterAssets == nil {
t.Fatal("memory character asset unit of work is missing")
}
if err := repos.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:1",
Stats: map[string]int64{"gold": 100},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{
CharacterID: "77",
Slots: map[string]repository.ItemStack{"0:0": {ItemID: 700, Count: 1}},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
if err := repos.Equipment.Save(ctx, repository.EquipmentRecord{
CharacterID: "77",
Entries: map[string]repository.EquipmentEntry{},
}); err != nil {
t.Fatalf("seed equipment: %v", err)
}
}
func assertOriginalCharacterAssets(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
character, _, _ := repos.Character.Load(ctx, "77")
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
if character.Stats["gold"] != 100 {
t.Fatalf("gold mutation escaped rollback: %+v", character.Stats)
}
if len(bag.Slots) != 1 || bag.Slots["0:0"].ItemID != 700 {
t.Fatalf("inventory mutation escaped rollback: %+v", bag.Slots)
}
if len(worn.Entries) != 0 {
t.Fatalf("equipment mutation escaped rollback: %+v", worn.Entries)
}
}
@@ -0,0 +1,295 @@
package memory
import (
"context"
"database/sql"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
"strings"
"sync"
"testing"
)
func TestMemoryCharacterPetUnitOfWorkCommitsAllThreeAggregates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterPetTransaction(t, ctx, repos)
err := repos.CharacterPets.WithinCharacterPets(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository, pets repository.PetRepository) error {
bag, _, err := inventory.Load(ctx, "77")
if err != nil {
return err
}
worn, _, err := equipment.Load(ctx, "77")
if err != nil {
return err
}
creatures, _, err := pets.Load(ctx, "77")
if err != nil {
return err
}
delete(bag.Slots, "7:3")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["24"] = repository.EquipmentEntry{SlotIndex: 24, ItemID: 63000}
creatures.EquippedKey = "9001"
entry := creatures.Entries["9001"]
entry.Exp = 12
creatures.Entries["9001"] = entry
if err := inventory.Save(ctx, bag); err != nil {
return err
}
if err := equipment.Save(ctx, worn); err != nil {
return err
}
return pets.Save(ctx, creatures)
})
if err != nil {
t.Fatalf("WithinCharacterPets() error = %v", err)
}
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
creatures, _, _ := repos.Pet.Load(ctx, "77")
if _, exists := bag.Slots["7:3"]; exists {
t.Fatalf("pet inventory mutation was not committed: %+v", bag.Slots)
}
if worn.Entries["24"].ItemID != 63000 || creatures.EquippedKey != "9001" || creatures.Entries["9001"].Exp != 12 {
t.Fatalf("pet aggregate transaction did not commit equipment=%+v pets=%+v", worn, creatures)
}
}
func TestMemoryCharacterPetUnitOfWorkRollsBackCallbackAndLatePetSaveFailure(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterPetTransaction(t, ctx, repos)
wantErr := errors.New("reject pet transaction")
err := repos.CharacterPets.WithinCharacterPets(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository, pets repository.PetRepository) error {
if err := mutateCharacterPetTransaction(ctx, inventory, equipment, pets); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("callback rollback error = %v, want %v", err, wantErr)
}
assertOriginalCharacterPetTransaction(t, ctx, repos)
commitErr := errors.New("pet commit failed")
uow := &memoryCharacterPetUnitOfWork{
inventory: repos.Inventory,
equipment: repos.Equipment,
pets: &mutatingPetStore{PetRepository: repos.Pet, saveErr: commitErr},
}
err = uow.WithinCharacterPets(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository, pets repository.PetRepository) error {
return mutateCharacterPetTransaction(ctx, inventory, equipment, pets)
})
if !errors.Is(err, commitErr) {
t.Fatalf("late commit error = %v, want %v", err, commitErr)
}
assertOriginalCharacterPetTransaction(t, ctx, repos)
}
func TestMemoryCharacterPetUnitOfWorkSerializesUpdates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterPetTransaction(t, ctx, repos)
const workers = 24
var wg sync.WaitGroup
errs := make(chan error, workers)
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repos.CharacterPets.WithinCharacterPets(ctx, "77", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository, pets repository.PetRepository) error {
bag, _, err := inventory.Load(ctx, "77")
if err != nil {
return err
}
creatures, _, err := pets.Load(ctx, "77")
if err != nil {
return err
}
stack := bag.Slots["7:3"]
stack.Count++
bag.Slots["7:3"] = stack
entry := creatures.Entries["9001"]
entry.Exp++
creatures.Entries["9001"] = entry
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return pets.Save(ctx, creatures)
})
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent pet update: %v", err)
}
}
bag, _, _ := repos.Inventory.Load(ctx, "77")
creatures, _, _ := repos.Pet.Load(ctx, "77")
if got := bag.Slots["7:3"].Count; got != workers+1 {
t.Fatalf("pet inventory count = %d, want %d", got, workers+1)
}
if got := creatures.Entries["9001"].Exp; got != workers {
t.Fatalf("pet exp = %d, want %d", got, workers)
}
}
func TestMySQLCharacterPetUnitOfWorkCommitsThreeWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
err = repos.CharacterPets.WithinCharacterPets(context.Background(), "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository, pets repository.PetRepository) error {
if err := inventory.Save(context.Background(), repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{}}); err != nil {
return err
}
if err := equipment.Save(context.Background(), repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{}}); err != nil {
return err
}
return pets.Save(context.Background(), repository.PetRecord{CharacterID: "77", Entries: map[string]repository.PetEntry{}})
})
if err != nil {
t.Fatalf("WithinCharacterPets() error = %v", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 1 || rollback != 0 {
t.Fatalf("mysql transaction begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
for _, table := range []string{"`dnf_s1_w1`.`dnf_inventories`", "`dnf_s1_w1`.`dnf_equipments`", "`dnf_s1_w1`.`dnf_pets`"} {
found := false
for _, query := range queries {
if strings.Contains(query, table) {
found = true
break
}
}
if !found {
t.Fatalf("mysql pet transaction queries %v do not include %s", queries, table)
}
}
}
func TestMySQLCharacterPetUnitOfWorkRollsBackCallbackFailure(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
err = repos.CharacterPets.WithinCharacterPets(context.Background(), "77", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository, pets repository.PetRepository) error {
if err := inventory.Save(context.Background(), repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{}}); err != nil {
return err
}
return pets.Save(context.Background(), repository.PetRecord{CharacterID: "78"})
})
if err == nil {
t.Fatal("WithinCharacterPets() accepted a cross-character pet write")
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 {
t.Fatalf("mysql rollback begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
}
func seedCharacterPetTransaction(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.CharacterPets == nil {
t.Fatal("character pet unit of work is missing")
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{
CharacterID: "77",
Slots: map[string]repository.ItemStack{"7:3": {ItemID: 63000, Count: 1}},
}); err != nil {
t.Fatalf("seed pet inventory: %v", err)
}
if err := repos.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{}}); err != nil {
t.Fatalf("seed pet equipment: %v", err)
}
if err := repos.Pet.Save(ctx, repository.PetRecord{
CharacterID: "77",
Entries: map[string]repository.PetEntry{
"9001": {PetKey: "9001", ItemID: 63000, Level: 1},
},
}); err != nil {
t.Fatalf("seed pet record: %v", err)
}
}
func mutateCharacterPetTransaction(ctx context.Context, inventory repository.InventoryRepository, equipment repository.EquipmentRepository, pets repository.PetRepository) error {
bag, _, err := inventory.Load(ctx, "77")
if err != nil {
return err
}
worn, _, err := equipment.Load(ctx, "77")
if err != nil {
return err
}
creatures, _, err := pets.Load(ctx, "77")
if err != nil {
return err
}
delete(bag.Slots, "7:3")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["24"] = repository.EquipmentEntry{SlotIndex: 24, ItemID: 63000}
creatures.EquippedKey = "9001"
entry := creatures.Entries["9001"]
entry.Exp = 99
creatures.Entries["9001"] = entry
if err := inventory.Save(ctx, bag); err != nil {
return err
}
if err := equipment.Save(ctx, worn); err != nil {
return err
}
return pets.Save(ctx, creatures)
}
func assertOriginalCharacterPetTransaction(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
creatures, _, _ := repos.Pet.Load(ctx, "77")
if bag.Slots["7:3"].Count != 1 || len(worn.Entries) != 0 || creatures.EquippedKey != "" || creatures.Entries["9001"].Exp != 0 {
t.Fatalf("pet transaction mutation escaped rollback inventory=%+v equipment=%+v pets=%+v", bag, worn, creatures)
}
}
type mutatingPetStore struct {
repository.PetRepository
saveErr error
failed bool
}
func (s *mutatingPetStore) Save(ctx context.Context, record repository.PetRecord) error {
if !s.failed {
s.failed = true
if err := s.PetRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.PetRepository.Save(ctx, record)
}
@@ -0,0 +1,437 @@
package memory
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"io"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
"strings"
"sync"
"testing"
platformdb "longheng.io/server/internal/platform/db"
)
func TestMemoryCharacterProgressionUnitOfWorkCommitsBothAggregates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
err := repos.CharacterProgression.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character, ok, err := characters.Load(ctx, "77")
if err != nil || !ok {
return errors.New("character missing")
}
skill, ok, err := skills.Load(ctx, "77")
if err != nil || !ok {
return errors.New("skill missing")
}
character.Level = 3
character.Stats["exp"] = 270
skill.Points = repository.SkillPointState{TotalSP: 110, RemainingSP: 85, TotalTP: 5, RemainingTP: 4, SyncedLevel: 3}
if err := repository.SaveCharacterFields(ctx, characters, character, repository.CharacterFieldBase, repository.CharacterFieldStats); err != nil {
return err
}
return repository.SaveSkillFields(ctx, skills, skill, repository.SkillFieldPoints)
})
if err != nil {
t.Fatalf("WithinCharacterProgression() error = %v", err)
}
character, _, _ := repos.Character.Load(ctx, "77")
skill, _, _ := repos.Skill.Load(ctx, "77")
if character.Level != 3 || character.Stats["exp"] != 270 {
t.Fatalf("character progression = level %d exp %d", character.Level, character.Stats["exp"])
}
if skill.Points != (repository.SkillPointState{TotalSP: 110, RemainingSP: 85, TotalTP: 5, RemainingTP: 4, SyncedLevel: 3}) {
t.Fatalf("skill points = %+v", skill.Points)
}
}
func TestMemoryCharacterProgressionUnitOfWorkRollsBackCallback(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
wantErr := errors.New("reject progression")
err := repos.CharacterProgression.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character, _, _ := characters.Load(ctx, "77")
skill, _, _ := skills.Load(ctx, "77")
character.Level = 3
character.Stats["exp"] = 270
skill.Points.TotalSP = 110
if err := characters.Save(ctx, character); err != nil {
return err
}
if err := skills.Save(ctx, skill); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinCharacterProgression() error = %v, want %v", err, wantErr)
}
assertOriginalCharacterProgression(t, ctx, repos.Character, repos.Skill)
}
func TestMemoryCharacterProgressionUnitOfWorkRestoresCharacterWhenSkillCommitFails(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
wantErr := errors.New("skill commit failed")
failingSkills := &mutatingProgressionSkillStore{SkillRepository: repos.Skill, saveErr: wantErr}
uow := &memoryCharacterProgressionUnitOfWork{character: repos.Character, skills: failingSkills}
err := uow.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character, _, _ := characters.Load(ctx, "77")
skill, _, _ := skills.Load(ctx, "77")
character.Level = 3
character.Stats["exp"] = 270
skill.Points.TotalSP = 110
if err := characters.Save(ctx, character); err != nil {
return err
}
return skills.Save(ctx, skill)
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinCharacterProgression() error = %v, want %v", err, wantErr)
}
assertOriginalCharacterProgression(t, ctx, repos.Character, repos.Skill)
}
func TestMemoryCharacterProgressionUnitOfWorkRestoresMutatingCharacterCommitFailure(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
wantErr := errors.New("character commit failed")
failingCharacters := &mutatingProgressionCharacterStore{CharacterRepository: repos.Character, saveErr: wantErr}
uow := &memoryCharacterProgressionUnitOfWork{character: failingCharacters, skills: repos.Skill}
err := uow.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character, _, _ := characters.Load(ctx, "77")
skill, _, _ := skills.Load(ctx, "77")
character.Level = 3
character.Stats["exp"] = 270
skill.Points.TotalSP = 110
if err := characters.Save(ctx, character); err != nil {
return err
}
return skills.Save(ctx, skill)
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinCharacterProgression() error = %v, want %v", err, wantErr)
}
assertOriginalCharacterProgression(t, ctx, repos.Character, repos.Skill)
}
func TestMemoryCharacterProgressionUnitOfWorkRejectsCrossCharacterAccess(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
err := repos.CharacterProgression.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, _ repository.SkillRepository) error {
_, _, err := characters.Load(ctx, "78")
return err
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-character error = %v, want ErrRecordKeyRequired", err)
}
assertOriginalCharacterProgression(t, ctx, repos.Character, repos.Skill)
}
func TestMemoryCharacterProgressionUnitOfWorkSerializesBothAggregates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterProgression(t, ctx, repos)
var wg sync.WaitGroup
errs := make(chan error, 30)
for range 30 {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repos.CharacterProgression.WithinCharacterProgression(ctx, "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character, _, err := characters.Load(ctx, "77")
if err != nil {
return err
}
skill, _, err := skills.Load(ctx, "77")
if err != nil {
return err
}
character.Stats["exp"]++
skill.Points.TotalSP++
skill.Points.RemainingSP++
if err := characters.Save(ctx, character); err != nil {
return err
}
return skills.Save(ctx, skill)
})
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent progression error = %v", err)
}
}
character, _, _ := repos.Character.Load(ctx, "77")
skill, _, _ := repos.Skill.Load(ctx, "77")
if character.Stats["exp"] != 270 || skill.Points.TotalSP != 130 || skill.Points.RemainingSP != 105 {
t.Fatalf("serialized progression character=%+v points=%+v", character, skill.Points)
}
}
func TestMySQLCharacterProgressionUnitOfWorkCommitsBothWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
err = repos.CharacterProgression.WithinCharacterProgression(context.Background(), "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character := repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Level: 2, Stats: map[string]int64{"exp": 120}}
skill := repository.SkillRecord{CharacterID: "77", Points: repository.SkillPointState{TotalSP: 30, RemainingSP: 30, SyncedLevel: 2}}
if err := repository.SaveCharacterFields(context.Background(), characters, character, repository.CharacterFieldBase, repository.CharacterFieldStats); err != nil {
return err
}
return repository.SaveSkillFields(context.Background(), skills, skill, repository.SkillFieldPoints)
})
if err != nil {
t.Fatalf("WithinCharacterProgression() error = %v", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 1 || rollback != 0 {
t.Fatalf("mysql transaction begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
joined := strings.Join(queries, "\n")
if !strings.Contains(joined, "`dnf_s1_w1`.`dnf_characters`") || !strings.Contains(joined, "`dnf_s1_w1`.`dnf_skills`") {
t.Fatalf("mysql progression queries = %v", queries)
}
}
func TestMySQLCharacterProgressionUnitOfWorkRollsBackAndScopesOwner(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
err = repos.CharacterProgression.WithinCharacterProgression(context.Background(), "77", func(characters repository.CharacterRepository, _ repository.SkillRepository) error {
return characters.Save(context.Background(), repository.CharacterRecord{CharacterID: "78"})
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-character error = %v, want ErrRecordKeyRequired", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 || len(queries) != 0 {
t.Fatalf("mysql rollback begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
}
func TestMySQLCharacterProgressionUnitOfWorkRollsBackBothExecutedWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
wantErr := errors.New("reject after both writes")
err = repos.CharacterProgression.WithinCharacterProgression(context.Background(), "77", func(characters repository.CharacterRepository, skills repository.SkillRepository) error {
character := repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Level: 2, Stats: map[string]int64{"exp": 120}}
skill := repository.SkillRecord{CharacterID: "77", Points: repository.SkillPointState{TotalSP: 30, RemainingSP: 30, SyncedLevel: 2}}
if err := repository.SaveCharacterFields(context.Background(), characters, character, repository.CharacterFieldBase, repository.CharacterFieldStats); err != nil {
return err
}
if err := repository.SaveSkillFields(context.Background(), skills, skill, repository.SkillFieldPoints); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinCharacterProgression() error = %v, want %v", err, wantErr)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 {
t.Fatalf("mysql rollback begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
}
func seedCharacterProgression(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.CharacterProgression == nil {
t.Fatal("character progression unit of work is missing")
}
if err := repos.Character.Save(ctx, repository.CharacterRecord{
CharacterID: "77",
AccountID: "dnf:1",
Level: 2,
Stats: map[string]int64{"exp": 240},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Skill.Save(ctx, repository.SkillRecord{
CharacterID: "77",
Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 75, TotalTP: 3, RemainingTP: 2, SyncedLevel: 2},
}); err != nil {
t.Fatalf("seed skill: %v", err)
}
}
func assertOriginalCharacterProgression(t *testing.T, ctx context.Context, characters repository.CharacterRepository, skills repository.SkillRepository) {
t.Helper()
character, _, _ := characters.Load(ctx, "77")
skill, _, _ := skills.Load(ctx, "77")
if character.Level != 2 || character.Stats["exp"] != 240 {
t.Fatalf("character mutation escaped rollback: %+v", character)
}
if skill.Points != (repository.SkillPointState{TotalSP: 100, RemainingSP: 75, TotalTP: 3, RemainingTP: 2, SyncedLevel: 2}) {
t.Fatalf("skill mutation escaped rollback: %+v", skill.Points)
}
}
type mutatingProgressionSkillStore struct {
repository.SkillRepository
saveErr error
failed bool
}
type mutatingProgressionCharacterStore struct {
repository.CharacterRepository
saveErr error
failed bool
}
func (s *mutatingProgressionCharacterStore) Save(ctx context.Context, record repository.CharacterRecord) error {
if !s.failed {
s.failed = true
if err := s.CharacterRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.CharacterRepository.Save(ctx, record)
}
func (s *mutatingProgressionSkillStore) Save(ctx context.Context, record repository.SkillRecord) error {
if !s.failed {
s.failed = true
if err := s.SkillRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.SkillRepository.Save(ctx, record)
}
type progressionSQLState struct {
mu sync.Mutex
begin int
commit int
rollback int
queries []string
}
func (s *progressionSQLState) snapshot() (int, int, int, []string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.begin, s.commit, s.rollback, append([]string(nil), s.queries...)
}
type progressionSQLConnector struct {
state *progressionSQLState
}
func (c progressionSQLConnector) Connect(context.Context) (driver.Conn, error) {
return &progressionSQLConn{state: c.state}, nil
}
func (c progressionSQLConnector) Driver() driver.Driver {
return progressionSQLDriver{state: c.state}
}
type progressionSQLDriver struct {
state *progressionSQLState
}
func (d progressionSQLDriver) Open(string) (driver.Conn, error) {
return &progressionSQLConn{state: d.state}, nil
}
type progressionSQLConn struct {
state *progressionSQLState
}
func (c *progressionSQLConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (c *progressionSQLConn) Close() error { return nil }
func (c *progressionSQLConn) Begin() (driver.Tx, error) {
return c.BeginTx(context.Background(), driver.TxOptions{})
}
func (c *progressionSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
c.state.mu.Lock()
c.state.begin++
c.state.mu.Unlock()
return &progressionSQLTx{state: c.state}, nil
}
func (c *progressionSQLConn) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) {
c.state.mu.Lock()
c.state.queries = append(c.state.queries, query)
c.state.mu.Unlock()
return driver.RowsAffected(1), nil
}
// QueryContext 对每次读都回答"没有行"。差异写的集合 writer 在写之前会先读出现有行;
// 空结果就等于"全是新增",这样记录的写入与原来的全量替换逐条相同。
//
// 移植上游 c0ebdce 的这 14 行:没有它,replaceStringMap / 集合 writer 一改成"先读
// 再写",这个假 driver 就会以 "prepare is not supported" 直接报错。
func (c *progressionSQLConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return progressionEmptyRows{}, nil
}
type progressionEmptyRows struct{}
func (progressionEmptyRows) Columns() []string { return nil }
func (progressionEmptyRows) Close() error { return nil }
func (progressionEmptyRows) Next([]driver.Value) error { return io.EOF }
type progressionSQLTx struct {
state *progressionSQLState
}
func (tx *progressionSQLTx) Commit() error {
tx.state.mu.Lock()
tx.state.commit++
tx.state.mu.Unlock()
return nil
}
func (tx *progressionSQLTx) Rollback() error {
tx.state.mu.Lock()
tx.state.rollback++
tx.state.mu.Unlock()
return nil
}
@@ -0,0 +1,378 @@
package memory
import (
"context"
"database/sql"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
"strconv"
"strings"
"sync"
"testing"
platformdb "longheng.io/server/internal/platform/db"
)
func TestMemoryRentalAssetUnitOfWorkCommitsAllAggregates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedRentalAssets(t, ctx, repos)
err := repository.WithinRentalAssetsScoped(ctx, repos.RentalAssets, "dnf:1", "77", func(
accounts repository.AccountRepository,
characters repository.CharacterRepository,
inventory repository.InventoryRepository,
equipment repository.EquipmentRepository,
) error {
account, _, _ := accounts.Load(ctx, "dnf:1")
character, _, _ := characters.Load(ctx, "77")
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
account.Metadata["rental_stars"] = "6"
character.Stats["gold"] = 80
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 1}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 9001}
if err := accounts.Save(ctx, account); err != nil {
return err
}
if err := repository.SaveCharacterFields(ctx, characters, character, repository.CharacterFieldStats); err != nil {
return err
}
if err := repository.SaveInventoryFields(ctx, inventory, bag, repository.InventoryFieldSlots); err != nil {
return err
}
return repository.SaveEquipmentFields(ctx, equipment, worn, repository.EquipmentFieldEntries)
})
if err != nil {
t.Fatalf("WithinRentalAssets() error = %v", err)
}
account, _, _ := repos.Account.Load(ctx, "dnf:1")
character, _, _ := repos.Character.Load(ctx, "77")
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
if account.Metadata["rental_stars"] != "6" || character.Stats["gold"] != 80 {
t.Fatalf("wallets account=%+v character=%+v", account.Metadata, character.Stats)
}
if bag.Slots["0:1"].ItemID != 9001 || worn.Entries["11"].ItemID != 9001 {
t.Fatalf("rental item bag=%+v equipment=%+v", bag.Slots, worn.Entries)
}
}
func TestMemoryRentalAssetUnitOfWorkRollsBackCallback(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedRentalAssets(t, ctx, repos)
wantErr := errors.New("reject rental")
err := repository.WithinRentalAssetsScoped(ctx, repos.RentalAssets, "dnf:1", "77", func(
accounts repository.AccountRepository,
characters repository.CharacterRepository,
inventory repository.InventoryRepository,
equipment repository.EquipmentRepository,
) error {
account, _, _ := accounts.Load(ctx, "dnf:1")
character, _, _ := characters.Load(ctx, "77")
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
account.Metadata["rental_stars"] = "0"
character.Stats["gold"] = 0
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 1}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 9001}
if err := accounts.Save(ctx, account); err != nil {
return err
}
if err := characters.Save(ctx, character); err != nil {
return err
}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
if err := equipment.Save(ctx, worn); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinRentalAssets() error = %v, want %v", err, wantErr)
}
assertOriginalRentalAssets(t, ctx, repos)
}
func TestMemoryRentalAssetUnitOfWorkRejectsCrossOwnerAccess(t *testing.T) {
tests := []struct {
name string
apply func(context.Context, repository.AccountRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error
}{
{"account", func(ctx context.Context, accounts repository.AccountRepository, _ repository.CharacterRepository, _ repository.InventoryRepository, _ repository.EquipmentRepository) error {
_, _, err := accounts.Load(ctx, "dnf:2")
return err
}},
{"character", func(ctx context.Context, _ repository.AccountRepository, characters repository.CharacterRepository, _ repository.InventoryRepository, _ repository.EquipmentRepository) error {
return characters.Save(ctx, repository.CharacterRecord{CharacterID: "78"})
}},
{"inventory", func(ctx context.Context, _ repository.AccountRepository, _ repository.CharacterRepository, inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
_, _, err := inventory.Load(ctx, "78")
return err
}},
{"equipment", func(ctx context.Context, _ repository.AccountRepository, _ repository.CharacterRepository, _ repository.InventoryRepository, equipment repository.EquipmentRepository) error {
return equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "78"})
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedRentalAssets(t, ctx, repos)
err := repository.WithinRentalAssetsScoped(ctx, repos.RentalAssets, "dnf:1", "77", func(a repository.AccountRepository, c repository.CharacterRepository, i repository.InventoryRepository, e repository.EquipmentRepository) error {
return test.apply(ctx, a, c, i, e)
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-owner error = %v, want ErrRecordKeyRequired", err)
}
assertOriginalRentalAssets(t, ctx, repos)
})
}
}
func TestMemoryRentalAssetUnitOfWorkRestoresEarlierCommits(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedRentalAssets(t, ctx, repos)
wantErr := errors.New("equipment commit failed")
failingEquipment := &mutatingEquipmentStore{EquipmentRepository: repos.Equipment, saveErr: wantErr}
uow := &memoryRentalAssetUnitOfWork{
account: repos.Account,
character: repos.Character,
inventory: repos.Inventory,
equipment: failingEquipment,
}
err := repository.WithinRentalAssetsScoped(ctx, uow, "dnf:1", "77", func(accounts repository.AccountRepository, characters repository.CharacterRepository, inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
account, _, _ := accounts.Load(ctx, "dnf:1")
character, _, _ := characters.Load(ctx, "77")
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
account.Metadata["rental_stars"] = "0"
character.Stats["gold"] = 0
bag.Slots["0:1"] = repository.ItemStack{ItemID: 9001, Count: 1}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 9001}
if err := accounts.Save(ctx, account); err != nil {
return err
}
if err := characters.Save(ctx, character); err != nil {
return err
}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return equipment.Save(ctx, worn)
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinRentalAssets() error = %v, want %v", err, wantErr)
}
assertOriginalRentalAssets(t, ctx, repos)
}
func TestMemoryRentalAssetUnitOfWorkSerializesWalletUpdates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedRentalAssets(t, ctx, repos)
var wg sync.WaitGroup
errs := make(chan error, 30)
for range 30 {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repository.WithinRentalAssetsScoped(ctx, repos.RentalAssets, "dnf:1", "77", func(accounts repository.AccountRepository, characters repository.CharacterRepository, _ repository.InventoryRepository, _ repository.EquipmentRepository) error {
account, _, err := accounts.Load(ctx, "dnf:1")
if err != nil {
return err
}
character, _, err := characters.Load(ctx, "77")
if err != nil {
return err
}
stars, err := strconv.Atoi(account.Metadata["rental_stars"])
if err != nil {
return err
}
account.Metadata["rental_stars"] = strconv.Itoa(stars + 1)
character.Stats["gold"]++
if err := accounts.Save(ctx, account); err != nil {
return err
}
return characters.Save(ctx, character)
})
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent rental update: %v", err)
}
}
account, _, _ := repos.Account.Load(ctx, "dnf:1")
character, _, _ := repos.Character.Load(ctx, "77")
if account.Metadata["rental_stars"] != "40" || character.Stats["gold"] != 130 {
t.Fatalf("serialized wallets account=%+v character=%+v", account.Metadata, character.Stats)
}
}
func TestMySQLRentalAssetUnitOfWorkCommitsFourWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
if _, ok := repos.RentalAssets.(repository.RentalAssetScopedUnitOfWork); ok {
t.Fatal("MySQL rental assets unexpectedly bypass the real SQL transaction fallback")
}
err = repository.WithinRentalAssetsScoped(context.Background(), repos.RentalAssets, "dnf:1", "77", func(accounts repository.AccountRepository, characters repository.CharacterRepository, inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
if err := accounts.Save(context.Background(), repository.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{"rental_stars": "6"}}); err != nil {
return err
}
if err := repository.SaveCharacterFields(context.Background(), characters, repository.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 80}}, repository.CharacterFieldStats); err != nil {
return err
}
if err := repository.SaveInventoryFields(context.Background(), inventory, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{"0:1": {ItemID: 9001, Count: 1}}}, repository.InventoryFieldSlots); err != nil {
return err
}
return repository.SaveEquipmentFields(context.Background(), equipment, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 9001}}}, repository.EquipmentFieldEntries)
})
if err != nil {
t.Fatalf("WithinRentalAssets() error = %v", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 1 || rollback != 0 {
t.Fatalf("mysql transaction begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
wantTables := []string{"dnf_accounts", "dnf_characters", "dnf_inventories", "dnf_equipments"}
joined := strings.Join(queries, "\n")
for _, table := range wantTables {
if !strings.Contains(joined, "`dnf_s1_w1`.`"+table+"`") {
t.Fatalf("queries = %q, want table %s", queries, table)
}
}
}
func TestMySQLRentalAssetUnitOfWorkRollsBackAndScopesBothOwners(t *testing.T) {
tests := []struct {
name string
apply func(repository.AccountRepository, repository.CharacterRepository) error
}{
{"account", func(accounts repository.AccountRepository, _ repository.CharacterRepository) error {
return accounts.Save(context.Background(), repository.AccountRecord{AccountID: "dnf:2"})
}},
{"character", func(_ repository.AccountRepository, characters repository.CharacterRepository) error {
return characters.Save(context.Background(), repository.CharacterRecord{CharacterID: "78"})
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
err = repository.WithinRentalAssetsScoped(context.Background(), repos.RentalAssets, "dnf:1", "77", func(accounts repository.AccountRepository, characters repository.CharacterRepository, _ repository.InventoryRepository, _ repository.EquipmentRepository) error {
return test.apply(accounts, characters)
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-owner error = %v, want ErrRecordKeyRequired", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 || len(queries) != 0 {
t.Fatalf("mysql rollback begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
})
}
}
func TestMySQLRentalAssetUnitOfWorkRollsBackExecutedWrites(t *testing.T) {
state := &progressionSQLState{}
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}}})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
wantErr := errors.New("reject after rental writes")
err = repository.WithinRentalAssetsScoped(context.Background(), repos.RentalAssets, "dnf:1", "77", func(accounts repository.AccountRepository, characters repository.CharacterRepository, inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
if err := accounts.Save(context.Background(), repository.AccountRecord{AccountID: "dnf:1"}); err != nil {
return err
}
if err := characters.Save(context.Background(), repository.CharacterRecord{CharacterID: "77"}); err != nil {
return err
}
if err := inventory.Save(context.Background(), repository.InventoryRecord{CharacterID: "77"}); err != nil {
return err
}
if err := equipment.Save(context.Background(), repository.EquipmentRecord{CharacterID: "77"}); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("WithinRentalAssets() error = %v, want %v", err, wantErr)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 0 || rollback != 1 {
t.Fatalf("mysql rollback begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
}
func seedRentalAssets(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.RentalAssets == nil {
t.Fatal("rental asset unit of work is missing")
}
if err := repos.Account.Save(ctx, repository.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{"rental_stars": "10"}}); err != nil {
t.Fatalf("seed account: %v", err)
}
if err := repos.Character.Save(ctx, repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Stats: map[string]int64{"gold": 100}}); err != nil {
t.Fatalf("seed character: %v", err)
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{"0:0": {ItemID: 700, Count: 1}}}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
if err := repos.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{}}); err != nil {
t.Fatalf("seed equipment: %v", err)
}
}
func assertOriginalRentalAssets(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
account, _, _ := repos.Account.Load(ctx, "dnf:1")
character, _, _ := repos.Character.Load(ctx, "77")
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
if account.Metadata["rental_stars"] != "10" || character.Stats["gold"] != 100 {
t.Fatalf("wallet mutation escaped rollback account=%+v character=%+v", account.Metadata, character.Stats)
}
if len(bag.Slots) != 1 || bag.Slots["0:0"].ItemID != 700 {
t.Fatalf("inventory mutation escaped rollback: %+v", bag.Slots)
}
if len(worn.Entries) != 0 {
t.Fatalf("equipment mutation escaped rollback: %+v", worn.Entries)
}
}
@@ -0,0 +1,315 @@
package memory
import (
"context"
"database/sql"
"errors"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/mysql"
"strings"
"testing"
platformdb "longheng.io/server/internal/platform/db"
)
func TestMemoryCharacterSettlementCommitsAllAggregates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterSettlement(t, ctx, repos)
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
character, _, _ := tx.Character.Load(ctx, "77")
quest, _, _ := tx.Quest.Load(ctx, "77")
skill, _, _ := tx.Skill.Load(ctx, "77")
inventory, _, _ := tx.Inventory.Load(ctx, "77")
equipment, _, _ := tx.Equipment.Load(ctx, "77")
character.Level = 3
quest.States[3145] = repository.QuestState{Status: "completed"}
skill.Points.TotalSP = 130
if inventory.Slots == nil {
inventory.Slots = make(map[string]repository.ItemStack)
}
inventory.Slots["0:1"] = repository.ItemStack{ItemID: 8474, Count: 1}
if equipment.Entries == nil {
equipment.Entries = make(map[string]repository.EquipmentEntry)
}
equipment.Entries["0"] = repository.EquipmentEntry{SlotIndex: 0, ItemID: 1001}
if err := repository.SaveCharacterFields(ctx, tx.Character, character, repository.CharacterFieldBase); err != nil {
return err
}
if err := repository.SaveQuestFields(ctx, tx.Quest, quest, repository.QuestFieldStates); err != nil {
return err
}
if err := repository.SaveSkillFields(ctx, tx.Skill, skill, repository.SkillFieldPoints); err != nil {
return err
}
if err := repository.SaveInventoryFields(ctx, tx.Inventory, inventory, repository.InventoryFieldSlots); err != nil {
return err
}
return repository.SaveEquipmentFields(ctx, tx.Equipment, equipment, repository.EquipmentFieldEntries)
})
if err != nil {
t.Fatalf("WithinCharacterSettlement() error = %v", err)
}
character, _, _ := repos.Character.Load(ctx, "77")
quest, _, _ := repos.Quest.Load(ctx, "77")
skill, _, _ := repos.Skill.Load(ctx, "77")
inventory, _, _ := repos.Inventory.Load(ctx, "77")
equipment, _, _ := repos.Equipment.Load(ctx, "77")
if character.Level != 3 || quest.States[3145].Status != "completed" || skill.Points.TotalSP != 130 ||
inventory.Slots["0:1"].ItemID != 8474 || equipment.Entries["0"].ItemID != 1001 {
t.Fatalf("settlement did not commit all aggregates: character=%+v quest=%+v skill=%+v inventory=%+v equipment=%+v", character, quest, skill, inventory, equipment)
}
}
func TestMemoryCharacterSettlementRollsBackCallbackAndRejectsCrossCharacter(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterSettlement(t, ctx, repos)
wantErr := errors.New("reject settlement")
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
character, _, _ := tx.Character.Load(ctx, "77")
character.Level = 9
if err := tx.Character.Save(ctx, character); err != nil {
return err
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("callback error = %v, want %v", err, wantErr)
}
assertOriginalCharacterSettlement(t, ctx, repos)
err = repos.CharacterSettlement.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
_, _, err := tx.Quest.Load(ctx, "78")
return err
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-character error = %v, want ErrRecordKeyRequired", err)
}
assertOriginalCharacterSettlement(t, ctx, repos)
}
func TestMemoryCharacterSettlementRestoresLateMutatingFailure(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterSettlement(t, ctx, repos)
wantErr := errors.New("equipment commit failed")
failingEquipment := &mutatingSettlementEquipmentStore{EquipmentRepository: repos.Equipment, saveErr: wantErr}
uow := &memoryCharacterSettlementUnitOfWork{
character: repos.Character,
quests: repos.Quest,
skills: repos.Skill,
inventory: repos.Inventory,
equipment: failingEquipment,
}
err := uow.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
character, _, _ := tx.Character.Load(ctx, "77")
quest, _, _ := tx.Quest.Load(ctx, "77")
skill, _, _ := tx.Skill.Load(ctx, "77")
inventory, _, _ := tx.Inventory.Load(ctx, "77")
equipment, _, _ := tx.Equipment.Load(ctx, "77")
character.Level = 9
quest.States[3145] = repository.QuestState{Status: "completed"}
skill.Points.TotalSP = 999
if inventory.Slots == nil {
inventory.Slots = make(map[string]repository.ItemStack)
}
inventory.Slots["0:1"] = repository.ItemStack{ItemID: 8474, Count: 1}
if equipment.Entries == nil {
equipment.Entries = make(map[string]repository.EquipmentEntry)
}
equipment.Entries["0"] = repository.EquipmentEntry{SlotIndex: 0, ItemID: 1001}
if err := tx.Character.Save(ctx, character); err != nil {
return err
}
if err := tx.Quest.Save(ctx, quest); err != nil {
return err
}
if err := tx.Skill.Save(ctx, skill); err != nil {
return err
}
if err := tx.Inventory.Save(ctx, inventory); err != nil {
return err
}
return tx.Equipment.Save(ctx, equipment)
})
if !errors.Is(err, wantErr) {
t.Fatalf("late commit error = %v, want %v", err, wantErr)
}
assertOriginalCharacterSettlement(t, ctx, repos)
}
func TestMemoryCharacterSettlementAccountCommitFailureRollsBackSettings(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterSettlement(t, ctx, repos)
if err := repos.Account.Save(ctx, repository.AccountRecord{
AccountID: "dnf:1",
Metadata: map[string]string{"before": "kept"},
}); err != nil {
t.Fatal(err)
}
wantErr := errors.New("account commit failed after mutation")
failingAccount := &mutatingSettlementAccountStore{AccountRepository: repos.Account, saveErr: wantErr}
uow := &memoryCharacterSettlementUnitOfWork{
account: failingAccount,
accountInventory: repos.AccountInventory,
character: repos.Character,
quests: repos.Quest,
skills: repos.Skill,
inventory: repos.Inventory,
equipment: repos.Equipment,
settings: repos.Settings,
}
scope := "character:77:dungeon_admission_counters_v1"
err := uow.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
if err := tx.Settings.Save(ctx, repository.SettingsRecord{
Scope: scope,
Values: map[string]string{"counter": "1"},
}); err != nil {
return err
}
account, found, err := tx.Account.Load(ctx, "dnf:1")
if err != nil || !found {
return errors.Join(err, errors.New("transaction account missing"))
}
account.Metadata["counter"] = "1"
return tx.Account.Save(ctx, account)
})
if !errors.Is(err, wantErr) {
t.Fatalf("account commit error=%v want=%v", err, wantErr)
}
if settings, found, loadErr := repos.Settings.Load(ctx, scope); loadErr != nil || found {
t.Fatalf("settings escaped failed account commit found=%t record=%+v err=%v", found, settings, loadErr)
}
account, found, err := repos.Account.Load(ctx, "dnf:1")
if err != nil || !found || account.Metadata["before"] != "kept" || account.Metadata["counter"] != "" {
t.Fatalf("account pre-image not restored found=%t account=%+v err=%v", found, account, err)
}
}
func TestMySQLCharacterSettlementCommitsFiveWritesAndScopesOwner(t *testing.T) {
state := &progressionSQLState{}
database := openProgressionSQLDB(t, state)
repos, err := mysql.NewMySQLGroupFromDB(database, mysql.MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if err != nil {
t.Fatalf("NewMySQLGroupFromDB() error = %v", err)
}
ctx := context.Background()
err = repos.CharacterSettlement.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
if err := tx.Character.Save(ctx, repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Level: 2}); err != nil {
return err
}
if err := tx.Quest.Save(ctx, repository.QuestRecord{CharacterID: "77", States: map[int64]repository.QuestState{3145: {Status: "completed"}}}); err != nil {
return err
}
if err := tx.Skill.Save(ctx, repository.SkillRecord{CharacterID: "77", Points: repository.SkillPointState{TotalSP: 30}}); err != nil {
return err
}
if err := tx.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{"0:1": {ItemID: 8474, Count: 1}}}); err != nil {
return err
}
return tx.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{"0": {ItemID: 1001}}})
})
if err != nil {
t.Fatalf("WithinCharacterSettlement() error = %v", err)
}
begin, commit, rollback, queries := state.snapshot()
if begin != 1 || commit != 1 || rollback != 0 {
t.Fatalf("mysql settlement begin=%d commit=%d rollback=%d queries=%v", begin, commit, rollback, queries)
}
joined := strings.Join(queries, "\n")
for _, table := range []string{"dnf_characters", "dnf_quests", "dnf_skills", "dnf_inventories", "dnf_equipments"} {
if !strings.Contains(joined, table) {
t.Fatalf("mysql settlement queries missing %s: %v", table, queries)
}
}
err = repos.CharacterSettlement.WithinCharacterSettlement(ctx, "77", func(tx repository.Group) error {
return tx.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "78"})
})
if !errors.Is(err, platformdb.ErrRecordKeyRequired) {
t.Fatalf("cross-character error = %v, want ErrRecordKeyRequired", err)
}
begin, commit, rollback, _ = state.snapshot()
if begin != 2 || commit != 1 || rollback != 1 {
t.Fatalf("mysql scoped rollback begin=%d commit=%d rollback=%d", begin, commit, rollback)
}
}
func openProgressionSQLDB(t *testing.T, state *progressionSQLState) *sql.DB {
t.Helper()
database := sql.OpenDB(progressionSQLConnector{state: state})
t.Cleanup(func() { _ = database.Close() })
return database
}
func seedCharacterSettlement(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.CharacterSettlement == nil {
t.Fatal("character settlement unit of work is missing")
}
mustSave := func(err error) {
if err != nil {
t.Fatalf("seed settlement: %v", err)
}
}
mustSave(repos.Character.Save(ctx, repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Level: 2, Stats: map[string]int64{"exp": 240}}))
mustSave(repos.Quest.Save(ctx, repository.QuestRecord{CharacterID: "77", States: map[int64]repository.QuestState{3145: {Status: "active"}}}))
mustSave(repos.Skill.Save(ctx, repository.SkillRecord{CharacterID: "77", Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 75}}))
mustSave(repos.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{}}))
mustSave(repos.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{}}))
}
func assertOriginalCharacterSettlement(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
character, _, _ := repos.Character.Load(ctx, "77")
quest, _, _ := repos.Quest.Load(ctx, "77")
skill, _, _ := repos.Skill.Load(ctx, "77")
inventory, _, _ := repos.Inventory.Load(ctx, "77")
equipment, _, _ := repos.Equipment.Load(ctx, "77")
if character.Level != 2 || character.Stats["exp"] != 240 || quest.States[3145].Status != "active" ||
skill.Points.TotalSP != 100 || len(inventory.Slots) != 0 || len(equipment.Entries) != 0 {
t.Fatalf("settlement mutation escaped rollback: character=%+v quest=%+v skill=%+v inventory=%+v equipment=%+v", character, quest, skill, inventory, equipment)
}
}
type mutatingSettlementEquipmentStore struct {
repository.EquipmentRepository
saveErr error
failed bool
}
type mutatingSettlementAccountStore struct {
repository.AccountRepository
saveErr error
failed bool
}
func (s *mutatingSettlementAccountStore) Save(ctx context.Context, record repository.AccountRecord) error {
if !s.failed {
s.failed = true
if err := s.AccountRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.AccountRepository.Save(ctx, record)
}
func (s *mutatingSettlementEquipmentStore) Save(ctx context.Context, record repository.EquipmentRecord) error {
if !s.failed {
s.failed = true
if err := s.EquipmentRepository.Save(ctx, record); err != nil {
return err
}
return s.saveErr
}
return s.EquipmentRepository.Save(ctx, record)
}
@@ -0,0 +1,361 @@
// 本文件由 transaction_test.go 按后端拆分而来。
package memory
import (
"context"
"errors"
"sync"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/repository"
)
func TestMemoryCharacterCreationUnitOfWorkCommitsCompleteAggregate(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
now := time.Now().UTC()
err := repos.CharacterCreate.WithinCharacterCreation(ctx, "77", repos, func(tx repository.Group) error {
if err := tx.Account.Save(ctx, repository.AccountRecord{AccountID: "dnf:1", State: "active"}); err != nil {
return err
}
if err := repository.CreateCharacter(ctx, tx.Character, repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Slot: 0, Level: 1}); err != nil {
return err
}
if err := tx.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77", Slots: map[string]repository.ItemStack{}, UpdatedAt: now}); err != nil {
return err
}
if err := tx.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{"11": {SlotIndex: 11, ItemID: 700}}, UpdatedAt: now}); err != nil {
return err
}
if err := tx.Skill.Save(ctx, repository.SkillRecord{CharacterID: "77", Skills: map[int64]repository.SkillState{46: {Level: 1, Enabled: true}}, UpdatedAt: now}); err != nil {
return err
}
return tx.Settings.Save(ctx, repository.SettingsRecord{Scope: "character:77:init", Values: map[string]string{"state": "ready"}, UpdatedAt: now})
})
if err != nil {
t.Fatalf("WithinCharacterCreation error = %v", err)
}
for name, loaded := range map[string]bool{
"account": recordExists(t, ctx, repos.Account, "dnf:1"),
"character": recordExists(t, ctx, repos.Character, "77"),
"inventory": recordExists(t, ctx, repos.Inventory, "77"),
"equipment": recordExists(t, ctx, repos.Equipment, "77"),
"skill": recordExists(t, ctx, repos.Skill, "77"),
"settings": recordExists(t, ctx, repos.Settings, "character:77:init"),
} {
if !loaded {
t.Fatalf("%s aggregate was not committed", name)
}
}
}
func TestMemoryCharacterCreationUnitOfWorkRollsBackEveryAggregate(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
rollback := errors.New("rollback")
err := repos.CharacterCreate.WithinCharacterCreation(ctx, "77", repos, func(tx repository.Group) error {
if err := tx.Account.Save(ctx, repository.AccountRecord{AccountID: "dnf:1"}); err != nil {
return err
}
if err := repository.CreateCharacter(ctx, tx.Character, repository.CharacterRecord{CharacterID: "77", AccountID: "dnf:1", Slot: 0, Level: 1}); err != nil {
return err
}
if err := tx.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "77"}); err != nil {
return err
}
if err := tx.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77"}); err != nil {
return err
}
if err := tx.Skill.Save(ctx, repository.SkillRecord{CharacterID: "77"}); err != nil {
return err
}
if err := tx.Settings.Save(ctx, repository.SettingsRecord{Scope: "character:77:init"}); err != nil {
return err
}
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("WithinCharacterCreation error = %v, want rollback", err)
}
for name, loaded := range map[string]bool{
"account": recordExists(t, ctx, repos.Account, "dnf:1"),
"character": recordExists(t, ctx, repos.Character, "77"),
"inventory": recordExists(t, ctx, repos.Inventory, "77"),
"equipment": recordExists(t, ctx, repos.Equipment, "77"),
"skill": recordExists(t, ctx, repos.Skill, "77"),
"settings": recordExists(t, ctx, repos.Settings, "character:77:init"),
} {
if loaded {
t.Fatalf("%s aggregate escaped rollback", name)
}
}
}
func recordExists[T any](t *testing.T, ctx context.Context, store interface {
Load(context.Context, string) (T, bool, error)
}, key string) bool {
t.Helper()
_, exists, err := store.Load(ctx, key)
if err != nil {
t.Fatalf("load %q: %v", key, err)
}
return exists
}
func TestMemoryCharacterItemUnitOfWorkCommitsBothStores(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterItems(t, ctx, repos)
err := repos.CharacterItems.WithinCharacterItems(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
bag, ok, err := inventory.Load(ctx, "77")
if err != nil || !ok {
return errors.New("inventory missing in transaction")
}
worn, ok, err := equipment.Load(ctx, "77")
if err != nil || !ok {
return errors.New("equipment missing in transaction")
}
delete(bag.Slots, "0:5")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 700}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return equipment.Save(ctx, worn)
})
if err != nil {
t.Fatalf("WithinCharacterItems error = %v", err)
}
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
if _, ok := bag.Slots["0:5"]; ok {
t.Fatalf("inventory mutation was not committed: %+v", bag.Slots)
}
if got := worn.Entries["11"].ItemID; got != 700 {
t.Fatalf("equipment item = %d, want 700", got)
}
}
func TestMemoryCharacterItemUnitOfWorkRollsBackBothStores(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterItems(t, ctx, repos)
rollback := errors.New("rollback")
err := repos.CharacterItems.WithinCharacterItems(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
delete(bag.Slots, "0:5")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 700}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
if err := equipment.Save(ctx, worn); err != nil {
return err
}
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("WithinCharacterItems error = %v, want rollback", err)
}
bag, _, _ := repos.Inventory.Load(ctx, "77")
worn, _, _ := repos.Equipment.Load(ctx, "77")
if got := bag.Slots["0:5"].ItemID; got != 700 {
t.Fatalf("inventory item = %d, want original 700", got)
}
if len(worn.Entries) != 0 {
t.Fatalf("equipment mutation escaped rollback: %+v", worn.Entries)
}
}
func TestMemoryCharacterItemUnitOfWorkRestoresInventoryWhenEquipmentCommitFails(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seedCharacterItems(t, ctx, repos)
commitErr := errors.New("equipment commit failed")
repos.CharacterItems = &memoryCharacterItemUnitOfWork{
inventory: repos.Inventory,
equipment: &failingEquipmentStore{EquipmentRepository: repos.Equipment, saveErr: commitErr},
}
err := repos.CharacterItems.WithinCharacterItems(ctx, "77", func(inventory repository.InventoryRepository, equipment repository.EquipmentRepository) error {
bag, _, _ := inventory.Load(ctx, "77")
worn, _, _ := equipment.Load(ctx, "77")
delete(bag.Slots, "0:5")
if worn.Entries == nil {
worn.Entries = make(map[string]repository.EquipmentEntry)
}
worn.Entries["11"] = repository.EquipmentEntry{SlotIndex: 11, ItemID: 700}
if err := inventory.Save(ctx, bag); err != nil {
return err
}
return equipment.Save(ctx, worn)
})
if !errors.Is(err, commitErr) {
t.Fatalf("WithinCharacterItems error = %v, want equipment commit failure", err)
}
bag, _, _ := repos.Inventory.Load(ctx, "77")
if got := bag.Slots["0:5"].ItemID; got != 700 {
t.Fatalf("inventory item = %d, want rollback to 700", got)
}
worn, _, _ := repos.Equipment.Load(ctx, "77")
if len(worn.Entries) != 0 {
t.Fatalf("equipment mutation escaped failed commit: %+v", worn.Entries)
}
}
func TestMemoryCharacterItemUnitOfWorkSerializesConcurrentUpdates(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{
CharacterID: "77",
Slots: map[string]repository.ItemStack{"0:5": {ItemID: 700, Count: 40}},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
if err := repos.Equipment.Save(ctx, repository.EquipmentRecord{CharacterID: "77", Entries: map[string]repository.EquipmentEntry{}}); err != nil {
t.Fatalf("seed equipment: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 40)
for range 40 {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repos.CharacterItems.WithinCharacterItems(ctx, "77", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
record, exists, err := inventory.Load(ctx, "77")
if err != nil || !exists {
return errors.New("inventory missing")
}
stack := record.Slots["0:5"]
stack.Count--
record.Slots["0:5"] = stack
return inventory.Save(ctx, record)
})
}()
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent update: %v", err)
}
}
record, _, _ := repos.Inventory.Load(ctx, "77")
if got := record.Slots["0:5"].Count; got != 0 {
t.Fatalf("final count = %d, want 0", got)
}
}
func TestMemoryCharacterSkillUnitOfWorkCommitsLevelsAndPoints(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seed := repository.SkillRecord{
CharacterID: "77",
Skills: map[int64]repository.SkillState{46: {Level: 1, Enabled: true}},
Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 80},
Layouts: map[int]repository.SkillLayout{0: {0: 46}},
}
if err := repos.Skill.Save(ctx, seed); err != nil {
t.Fatalf("seed skill: %v", err)
}
err := repos.CharacterSkills.WithinCharacterSkill(ctx, "77", func(skills repository.SkillRepository) error {
record, ok, err := skills.Load(ctx, "77")
if err != nil || !ok {
return errors.New("skill record missing in transaction")
}
state := record.Skills[46]
state.Level = 2
record.Skills[46] = state
record.Points.RemainingSP = 60
delete(record.Layouts[0], 0)
record.Layouts[0][1] = 46
return repository.SaveSkillFields(ctx, skills, record, repository.SkillFieldSkills, repository.SkillFieldPoints, repository.SkillFieldLayouts)
})
if err != nil {
t.Fatalf("WithinCharacterSkill error = %v", err)
}
record, _, _ := repos.Skill.Load(ctx, "77")
if record.Skills[46].Level != 2 || record.Points.RemainingSP != 60 || record.Layouts[0][1] != 46 {
t.Fatalf("skill transaction was not committed atomically: %+v", record)
}
}
func TestMemoryCharacterSkillUnitOfWorkRollsBackLevelsAndPoints(t *testing.T) {
ctx := context.Background()
repos := NewMemoryGroup()
seed := repository.SkillRecord{
CharacterID: "77",
Skills: map[int64]repository.SkillState{46: {Level: 1, Enabled: true}},
Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 80},
Layouts: map[int]repository.SkillLayout{0: {0: 46}},
}
if err := repos.Skill.Save(ctx, seed); err != nil {
t.Fatalf("seed skill: %v", err)
}
rollback := errors.New("rollback")
err := repos.CharacterSkills.WithinCharacterSkill(ctx, "77", func(skills repository.SkillRepository) error {
record, _, _ := skills.Load(ctx, "77")
state := record.Skills[46]
state.Level = 2
record.Skills[46] = state
record.Points.RemainingSP = 60
delete(record.Layouts[0], 0)
record.Layouts[0][1] = 46
if err := repository.SaveSkillFields(ctx, skills, record, repository.SkillFieldSkills, repository.SkillFieldPoints, repository.SkillFieldLayouts); err != nil {
return err
}
return rollback
})
if !errors.Is(err, rollback) {
t.Fatalf("WithinCharacterSkill error = %v, want rollback", err)
}
record, _, _ := repos.Skill.Load(ctx, "77")
if record.Skills[46].Level != 1 || record.Points.RemainingSP != 80 || record.Layouts[0][0] != 46 {
t.Fatalf("skill mutation escaped rollback: %+v", record)
}
}
func seedCharacterItems(t *testing.T, ctx context.Context, repos repository.Group) {
t.Helper()
if repos.CharacterItems == nil {
t.Fatal("memory character item unit of work is missing")
}
if err := repos.Inventory.Save(ctx, repository.InventoryRecord{
CharacterID: "77",
Slots: map[string]repository.ItemStack{"0:5": {ItemID: 700, Count: 1}},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
if err := repos.Equipment.Save(ctx, repository.EquipmentRecord{
CharacterID: "77",
Entries: map[string]repository.EquipmentEntry{},
}); err != nil {
t.Fatalf("seed equipment: %v", err)
}
}
type failingEquipmentStore struct {
repository.EquipmentRepository
saveErr error
}
func (s *failingEquipmentStore) Save(context.Context, repository.EquipmentRecord) error {
return s.saveErr
}