DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,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
|
||||
}
|
||||
+362
@@ -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
+199
@@ -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)
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user