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,110 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// 账号作用域的准入计数(周常五图共享额度)必须真的落库并能跨重启读回。
|
||||
//
|
||||
// 这条覆盖的现象:角色侧策略写进 dnf_settings 后能持久化,而账号侧策略
|
||||
// 走 Account.Save -> replaceStringMap;如果哪一环丢掉了 Metadata,额度就会
|
||||
// 每次重启后归零,玩家可以无限刷。
|
||||
func TestAccountAdmissionCounterPersistsAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{
|
||||
AccountID: "account-1", State: "active", Metadata: map[string]string{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creator := group.Character.(repository.CharacterCreator)
|
||||
if err := creator.CreateCharacter(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001", AccountID: "account-1", Slot: 0, Name: "hero", Level: 90,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner, err := dungeonadmission.NewOwner(group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 共享账号额度 5 次 + 角色上限 2 次,模拟周常五图的一条策略组。
|
||||
policies := []dungeonadmission.Policy{
|
||||
{
|
||||
Key: "abis.weekly_day.account.daily", SourcePath: "test", SourceSection: "default enterable count",
|
||||
Scope: dungeonadmission.ScopeAccount, Period: dungeonadmission.PeriodDaily, Limit: 5,
|
||||
DungeonIDs: []uint32{4906},
|
||||
},
|
||||
{
|
||||
Key: "abis.weekly_day.4906.character.daily", SourcePath: "test", SourceSection: "dungeon enter max count",
|
||||
Scope: dungeonadmission.ScopeCharacter, Period: dungeonadmission.PeriodDaily, Limit: 2,
|
||||
DungeonIDs: []uint32{4906},
|
||||
},
|
||||
}
|
||||
now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
snapshots, err := owner.Commit(ctx, "1001", policies, "restart-receipt-1", now)
|
||||
if err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
for _, s := range snapshots {
|
||||
t.Logf("policy=%s scope=%d used=%d limit=%d", s.Policy.Key, s.Policy.Scope, s.Used, s.EffectiveLimit)
|
||||
}
|
||||
// Stop 会把内存态落盘(含 checkpoint),模拟服务端正常关闭。
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatalf("stop component: %v", err)
|
||||
}
|
||||
database.Close()
|
||||
|
||||
// 重启:重新打开同一个库文件。
|
||||
restarted, restartedDB := startTestComponent(t, path)
|
||||
defer restartedDB.Close()
|
||||
restartedGroup, ok := restarted.Group()
|
||||
if !ok {
|
||||
t.Fatal("restarted repository group is unavailable")
|
||||
}
|
||||
|
||||
account, found, err := restartedGroup.Account.Load(ctx, "account-1")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load account after restart: found=%v err=%v", found, err)
|
||||
}
|
||||
admissionKeys := 0
|
||||
for key, value := range account.Metadata {
|
||||
if len(key) >= len("dungeon_admission_counter_v1:") &&
|
||||
key[:len("dungeon_admission_counter_v1:")] == "dungeon_admission_counter_v1:" {
|
||||
admissionKeys++
|
||||
t.Logf("account metadata %s = %s", key, value)
|
||||
}
|
||||
}
|
||||
if admissionKeys == 0 {
|
||||
t.Fatalf("account admission counters did not survive restart; metadata=%v", account.Metadata)
|
||||
}
|
||||
|
||||
// 重启后必须用**新** group 的 owner(旧 owner 持有已关闭组件的句柄)。
|
||||
restartedOwner, err := dungeonadmission.NewOwner(restartedGroup)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重启后已用次数必须仍可见:第 2 次提交通过,第 3 次因角色上限 2 被拒绝。
|
||||
if _, err := restartedOwner.Commit(ctx, "1001", policies, "restart-receipt-2", now); err != nil {
|
||||
t.Fatalf("second commit after restart: %v", err)
|
||||
}
|
||||
if _, err := restartedOwner.Commit(ctx, "1001", policies, "restart-receipt-3", now); err == nil {
|
||||
t.Fatal("third commit must be rejected by character limit 2")
|
||||
} else {
|
||||
t.Logf("third commit rejected as expected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// ReadCompanionSource 是支援兵(companion)需要的「一致只读快照」能力。
|
||||
//
|
||||
// 为什么必须是一个快照
|
||||
// 支援兵要拿源角色的角色记录 + 装备 + 技能三份数据去构造一个 AIC 投影。
|
||||
// 如果这三份各自读一次,中间被一次存档写入穿插,就会拼出"装备是新的、
|
||||
// 技能是旧的"这种客户端读不了的状态。所以整组读取必须在同一个读锁里完成。
|
||||
//
|
||||
// 为什么用 component.read
|
||||
// Component.read 持 mu.RLock 后在 runtime 的 Group 上调 apply,
|
||||
// 期间所有写路径都被挡住 —— 这正是"一致快照"的实现方式,且不需要
|
||||
// 另开一个只读事务(本仓储是内存态 + 异步落盘)。
|
||||
//
|
||||
// 为什么 rollback/只读语义没有照搬 MySQL 版
|
||||
// 参考实现(mysql_companion_read.go)用 sql.TxOptions{ReadOnly} 开事务,
|
||||
// 是因为 MySQL 版真的是逐行 SQL。本仓储的读取本身不产生写入,
|
||||
// 而 Clone* 只负责把聚合从调用方手里摘开,所以这里不需要 ReadOnly 包装器。
|
||||
func (s *characterStore) ReadCompanionSource(
|
||||
ctx context.Context,
|
||||
accountID, ownerID, sourceID string,
|
||||
) (repository.CompanionSourceState, error) {
|
||||
empty := repository.CompanionSourceState{}
|
||||
if s == nil || s.component == nil {
|
||||
return empty, repository.ErrCompanionReadUnavailable
|
||||
}
|
||||
if strings.TrimSpace(accountID) == "" || accountID != strings.TrimSpace(accountID) ||
|
||||
!companionNumericID(ownerID) || !companionNumericID(sourceID) || ownerID == sourceID {
|
||||
return empty, repository.ErrCompanionSelection
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
var result repository.CompanionSourceState
|
||||
err := s.component.read(ctx, func(group repository.Group) error {
|
||||
if group.Character == nil || group.Equipment == nil || group.Skill == nil {
|
||||
return repository.ErrCompanionReadUnavailable
|
||||
}
|
||||
var found bool
|
||||
var loadErr error
|
||||
result.Owner, found, loadErr = group.Character.Load(ctx, ownerID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found || result.Owner.Stats["delete_flag"] != 0 {
|
||||
return repository.ErrCompanionCharacterUnavailable
|
||||
}
|
||||
result.Source, found, loadErr = group.Character.Load(ctx, sourceID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found || result.Source.Stats["delete_flag"] != 0 {
|
||||
return repository.ErrCompanionCharacterUnavailable
|
||||
}
|
||||
// 两个角色都必须在同一个账号名下:支援兵是"借用自己账号的另一个角色",
|
||||
// 跨账号借用会让借用人的装备/技能快照泄漏给别人。
|
||||
if result.Owner.AccountID != accountID || result.Source.AccountID != accountID {
|
||||
return repository.ErrCompanionAccountMismatch
|
||||
}
|
||||
result.Equipment, result.HasEquipment, loadErr = group.Equipment.Load(ctx, sourceID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
result.Skill, result.HasSkill, loadErr = group.Skill.Load(ctx, sourceID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
// 摘开聚合:调用方随后会在自己的 goroutine 里读这些记录,
|
||||
// 不能让它和仓储内部的可变状态共享 map/切片。
|
||||
result.Owner = repository.CloneCharacter(result.Owner)
|
||||
result.Source = repository.CloneCharacter(result.Source)
|
||||
result.Equipment = repository.CloneEquipment(result.Equipment)
|
||||
result.Skill = repository.CloneSkill(result.Skill)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// companionNumericID 要求是规范的十进制 uint16(不含前导零/符号/空白),
|
||||
// 与 MySQL 版保持同一套校验,避免 "007" 与 "7" 被当成两个不同角色。
|
||||
func companionNumericID(value string) bool {
|
||||
id, err := strconv.ParseUint(value, 10, 16)
|
||||
return err == nil && id != 0 && strconv.FormatUint(id, 10) == value
|
||||
}
|
||||
|
||||
var _ repository.CompanionSourceReader = (*characterStore)(nil)
|
||||
@@ -0,0 +1,576 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
var ErrDatabaseRequired = errors.New("sqlite repository database is required")
|
||||
|
||||
const (
|
||||
checkpointModePassive = "PASSIVE"
|
||||
checkpointModeTruncate = "TRUNCATE"
|
||||
periodicSyncInterval = time.Minute
|
||||
|
||||
sqliteSnapshotSyncSourcePeriodic = "periodic"
|
||||
sqliteSnapshotSyncSourceStop = "stop"
|
||||
)
|
||||
|
||||
var periodicSyncEvery = periodicSyncInterval
|
||||
|
||||
type checkpointResult struct {
|
||||
Busy int
|
||||
LogFrames int
|
||||
CheckpointedFrames int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Driver string `json:"driver"`
|
||||
Path string `json:"path"`
|
||||
Started bool `json:"started"`
|
||||
StateVersion int `json:"state_version"`
|
||||
RecordCount int `json:"record_count"`
|
||||
Dirty bool `json:"dirty"`
|
||||
SyncCount uint64 `json:"sync_count"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
mu sync.RWMutex
|
||||
mutationGate chan struct{}
|
||||
db *sql.DB
|
||||
path string
|
||||
runtime *memory.Runtime
|
||||
persisted memory.State
|
||||
group repository.Group
|
||||
started bool
|
||||
dirty bool
|
||||
syncCancel context.CancelFunc
|
||||
syncDone chan struct{}
|
||||
syncCount uint64
|
||||
lastSyncAt time.Time
|
||||
lastSyncError string
|
||||
}
|
||||
|
||||
func NewComponent(database *sql.DB, path string) *Component {
|
||||
return &Component{
|
||||
db: database,
|
||||
path: strings.TrimSpace(path),
|
||||
mutationGate: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Component) acquireMutation(ctx context.Context) error {
|
||||
if c == nil || c.mutationGate == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
select {
|
||||
case c.mutationGate <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Component) releaseMutation() {
|
||||
if c != nil && c.mutationGate != nil {
|
||||
<-c.mutationGate
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Component) Name() string { return "dnf-repository-sqlite" }
|
||||
|
||||
func (c *Component) Preflight(ctx context.Context) error {
|
||||
if c == nil || c.db == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return c.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (c *Component) Start(ctx context.Context) error {
|
||||
if c == nil || c.db == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.started {
|
||||
return nil
|
||||
}
|
||||
if err := configureDatabase(ctx, c.db); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("create sqlite repository schema: %w", err)
|
||||
}
|
||||
state, err := loadState(ctx, c.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime, err := memory.NewRuntimeFromState(ctx, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore sqlite repository state: %w", err)
|
||||
}
|
||||
c.runtime = runtime
|
||||
c.group = buildGroup(c)
|
||||
c.started = true
|
||||
c.dirty = false
|
||||
persisted := runtime.Snapshot()
|
||||
if err := persistTransition(ctx, c.db, state, persisted); err != nil {
|
||||
c.started = false
|
||||
c.group = repository.Group{}
|
||||
c.runtime = nil
|
||||
return err
|
||||
}
|
||||
c.persisted = persisted
|
||||
c.startPeriodicSyncLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceState validates and atomically replaces every domain record in a
|
||||
// SQLite repository. It is intended for explicit migration and release-save
|
||||
// export tools; normal game writes continue to flow through Component.
|
||||
func ReplaceState(ctx context.Context, database *sql.DB, state memory.State) error {
|
||||
if database == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Version == 0 {
|
||||
state.Version = memory.StateVersion
|
||||
}
|
||||
if _, err := memory.NewRuntimeFromState(ctx, state); err != nil {
|
||||
return fmt.Errorf("validate sqlite replacement state: %w", err)
|
||||
}
|
||||
if err := configureDatabase(ctx, database); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("create sqlite repository schema: %w", err)
|
||||
}
|
||||
before, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return persistTransition(ctx, database, before, state)
|
||||
}
|
||||
|
||||
func configureDatabase(ctx context.Context, database *sql.DB) error {
|
||||
for _, pragma := range []string{
|
||||
`PRAGMA foreign_keys = ON`,
|
||||
`PRAGMA journal_mode = WAL`,
|
||||
`PRAGMA synchronous = FULL`,
|
||||
`PRAGMA busy_timeout = 5000`,
|
||||
} {
|
||||
if _, err := database.ExecContext(ctx, pragma); err != nil {
|
||||
return fmt.Errorf("configure sqlite repository: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Component) Stop(ctx context.Context) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := c.stopPeriodicSync(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.acquireMutation(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.releaseMutation()
|
||||
c.mu.Lock()
|
||||
var syncErr error
|
||||
if c.started && c.runtime != nil {
|
||||
syncErr = c.syncRuntimeLocked(ctx, checkpointModeTruncate, sqliteSnapshotSyncSourceStop)
|
||||
}
|
||||
c.started = false
|
||||
c.dirty = false
|
||||
c.group = repository.Group{}
|
||||
c.runtime = nil
|
||||
c.persisted = memory.State{}
|
||||
c.mu.Unlock()
|
||||
return syncErr
|
||||
}
|
||||
|
||||
func (c *Component) Group() (repository.Group, bool) {
|
||||
if c == nil {
|
||||
return repository.Group{}, false
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.group, c.started
|
||||
}
|
||||
|
||||
func (c *Component) Snapshot() Snapshot {
|
||||
if c == nil {
|
||||
return Snapshot{Driver: "sqlite"}
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
snapshot := Snapshot{
|
||||
Driver: "sqlite",
|
||||
Path: c.path,
|
||||
Started: c.started,
|
||||
StateVersion: memory.StateVersion,
|
||||
Dirty: c.dirty,
|
||||
SyncCount: c.syncCount,
|
||||
LastSyncError: c.lastSyncError,
|
||||
}
|
||||
if !c.lastSyncAt.IsZero() {
|
||||
snapshot.LastSyncAt = c.lastSyncAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if c.runtime != nil {
|
||||
state := c.runtime.Snapshot()
|
||||
snapshot.RecordCount = len(state.Accounts) + len(state.AccountInventories) + len(state.Characters) + len(state.Inventories) + len(state.Equipment) + len(state.Pets) + len(state.Quests) + len(state.Skills) + len(state.DungeonPermissions) + len(state.HellPartyPermissions) + len(state.PacketTemplates) + len(state.Settings) + len(state.Mailboxes) + len(state.EpicBooks) + len(state.UnitedFriends)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (c *Component) check(ctx context.Context) error {
|
||||
if c == nil || c.db == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.db.PingContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.lastSyncError != "" {
|
||||
return fmt.Errorf("sqlite background save is unhealthy: %s", c.lastSyncError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Component) read(ctx context.Context, apply func(repository.Group) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if !c.started || c.runtime == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
return apply(c.runtime.Group())
|
||||
}
|
||||
|
||||
func (c *Component) mutateAsync(ctx context.Context, apply func(repository.Group) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.acquireMutation(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.releaseMutation()
|
||||
c.mu.RLock()
|
||||
if !c.started || c.runtime == nil {
|
||||
c.mu.RUnlock()
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
group := c.runtime.Group()
|
||||
c.mu.RUnlock()
|
||||
if err := apply(group); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.dirty = true
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Component) mutateSync(ctx context.Context, apply func(repository.Group) error) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.acquireMutation(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.releaseMutation()
|
||||
c.mu.RLock()
|
||||
if !c.started || c.runtime == nil {
|
||||
c.mu.RUnlock()
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
before := c.runtime.Snapshot()
|
||||
persistedBefore := c.persisted
|
||||
dirtyBefore := c.dirty
|
||||
group := c.runtime.Group()
|
||||
c.mu.RUnlock()
|
||||
if err := apply(group); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
after := c.runtime.Snapshot()
|
||||
if err := persistTransition(ctx, c.db, persistedBefore, after); err != nil {
|
||||
restored, restoreErr := memory.NewRuntimeFromState(context.Background(), before)
|
||||
if restoreErr == nil {
|
||||
c.runtime = restored
|
||||
}
|
||||
c.dirty = dirtyBefore
|
||||
return errors.Join(err, restoreErr)
|
||||
}
|
||||
c.persisted = after
|
||||
c.dirty = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// mutateCharacterAssetsSync keeps the same durable, all-or-nothing contract
|
||||
// as mutateSync, but its persistence boundary is deliberately the three
|
||||
// records a CharacterAssetUnitOfWork is allowed to mutate. In particular a
|
||||
// dungeon pickup must not serialize/checksum every quest, mailbox and other
|
||||
// character record merely to add one stack to the selected character's bag.
|
||||
//
|
||||
// The component-wide mutation gate remains necessary: the in-memory aggregate
|
||||
// and SQLite have one authoritative writer. Narrowing the persisted delta
|
||||
// shortens that gate's hold time without weakening transaction ordering.
|
||||
func (c *Component) mutateCharacterAssetsSync(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
apply func(repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error,
|
||||
) error {
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" || apply == nil {
|
||||
return repository.ErrCharacterAssetTransactionUnavailable
|
||||
}
|
||||
return c.mutateRecordScopeSync(ctx, []scopedRecordRef{
|
||||
{kind: kindCharacter, key: characterID},
|
||||
{kind: kindInventory, key: characterID},
|
||||
{kind: kindEquipment, key: characterID},
|
||||
}, func(group repository.Group) error {
|
||||
return group.CharacterAssets.WithinCharacterAssets(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
type characterAssetsSnapshot struct {
|
||||
character repository.CharacterRecord
|
||||
characterFound bool
|
||||
inventory repository.InventoryRecord
|
||||
inventoryFound bool
|
||||
equipment repository.EquipmentRecord
|
||||
equipmentFound bool
|
||||
}
|
||||
|
||||
func loadCharacterAssetsSnapshot(ctx context.Context, group repository.Group, characterID string) (characterAssetsSnapshot, error) {
|
||||
character, characterFound, err := group.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return characterAssetsSnapshot{}, err
|
||||
}
|
||||
inventory, inventoryFound, err := group.Inventory.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return characterAssetsSnapshot{}, err
|
||||
}
|
||||
equipment, equipmentFound, err := group.Equipment.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return characterAssetsSnapshot{}, err
|
||||
}
|
||||
return characterAssetsSnapshot{
|
||||
character: repository.CloneCharacter(character),
|
||||
characterFound: characterFound,
|
||||
inventory: repository.CloneInventory(inventory),
|
||||
inventoryFound: inventoryFound,
|
||||
equipment: repository.CloneEquipment(equipment),
|
||||
equipmentFound: equipmentFound,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyCharacterAssetsSnapshotToState(state *memory.State, snapshot characterAssetsSnapshot) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
if snapshot.characterFound {
|
||||
state.Characters = replaceCharacterStateRecord(state.Characters, snapshot.character)
|
||||
}
|
||||
if snapshot.inventoryFound {
|
||||
state.Inventories = replaceInventoryStateRecord(state.Inventories, snapshot.inventory)
|
||||
}
|
||||
if snapshot.equipmentFound {
|
||||
state.Equipment = replaceEquipmentStateRecord(state.Equipment, snapshot.equipment)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceCharacterStateRecord(records []repository.CharacterRecord, record repository.CharacterRecord) []repository.CharacterRecord {
|
||||
for index := range records {
|
||||
if records[index].CharacterID == record.CharacterID {
|
||||
records[index] = repository.CloneCharacter(record)
|
||||
return records
|
||||
}
|
||||
}
|
||||
return append(records, repository.CloneCharacter(record))
|
||||
}
|
||||
|
||||
func replaceInventoryStateRecord(records []repository.InventoryRecord, record repository.InventoryRecord) []repository.InventoryRecord {
|
||||
for index := range records {
|
||||
if records[index].CharacterID == record.CharacterID {
|
||||
records[index] = repository.CloneInventory(record)
|
||||
return records
|
||||
}
|
||||
}
|
||||
return append(records, repository.CloneInventory(record))
|
||||
}
|
||||
|
||||
func replaceEquipmentStateRecord(records []repository.EquipmentRecord, record repository.EquipmentRecord) []repository.EquipmentRecord {
|
||||
for index := range records {
|
||||
if records[index].CharacterID == record.CharacterID {
|
||||
records[index] = repository.CloneEquipment(record)
|
||||
return records
|
||||
}
|
||||
}
|
||||
return append(records, repository.CloneEquipment(record))
|
||||
}
|
||||
|
||||
func (c *Component) now() time.Time { return time.Now().UTC() }
|
||||
|
||||
func (c *Component) startPeriodicSyncLocked() {
|
||||
if c == nil || c.syncCancel != nil || periodicSyncEvery <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
c.syncCancel = cancel
|
||||
c.syncDone = done
|
||||
go c.runPeriodicSync(ctx, done, periodicSyncEvery)
|
||||
}
|
||||
|
||||
func (c *Component) runPeriodicSync(ctx context.Context, done chan<- struct{}, interval time.Duration) {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = c.syncRuntime(ctx, checkpointModePassive, sqliteSnapshotSyncSourcePeriodic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Component) stopPeriodicSync(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
cancel := c.syncCancel
|
||||
done := c.syncDone
|
||||
c.syncCancel = nil
|
||||
c.syncDone = nil
|
||||
c.mu.Unlock()
|
||||
if cancel == nil || done == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("stop sqlite periodic save: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Component) syncRuntime(ctx context.Context, checkpointMode, source string) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := c.acquireMutation(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.releaseMutation()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.started || c.runtime == nil {
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
return c.syncRuntimeLocked(ctx, checkpointMode, source)
|
||||
}
|
||||
|
||||
func (c *Component) syncRuntimeLocked(ctx context.Context, checkpointMode, source string) error {
|
||||
var err error
|
||||
if c.dirty {
|
||||
state := c.runtime.Snapshot()
|
||||
err = persistTransition(ctx, c.db, c.persisted, state)
|
||||
if err == nil {
|
||||
c.persisted = state
|
||||
}
|
||||
if err == nil {
|
||||
c.dirty = false
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
_, err = checkpointDatabase(ctx, c.db, checkpointMode)
|
||||
}
|
||||
c.syncCount++
|
||||
c.lastSyncAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
c.lastSyncError = fmt.Sprintf("%s: %v", source, err)
|
||||
return fmt.Errorf("%s sqlite repository save: %w", source, err)
|
||||
}
|
||||
c.lastSyncError = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkpointDatabase(ctx context.Context, database *sql.DB, mode string) (checkpointResult, error) {
|
||||
if database == nil {
|
||||
return checkpointResult{}, ErrDatabaseRequired
|
||||
}
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if mode != checkpointModePassive && mode != checkpointModeTruncate {
|
||||
return checkpointResult{}, fmt.Errorf("unsupported sqlite checkpoint mode %q", mode)
|
||||
}
|
||||
var result checkpointResult
|
||||
err := database.QueryRowContext(ctx, "PRAGMA wal_checkpoint("+mode+")").Scan(
|
||||
&result.Busy,
|
||||
&result.LogFrames,
|
||||
&result.CheckpointedFrames,
|
||||
)
|
||||
if err != nil {
|
||||
return checkpointResult{}, fmt.Errorf("checkpoint sqlite repository: %w", err)
|
||||
}
|
||||
if result.Busy != 0 {
|
||||
return result, fmt.Errorf(
|
||||
"checkpoint sqlite repository is busy (log_frames=%d checkpointed_frames=%d)",
|
||||
result.LogFrames,
|
||||
result.CheckpointedFrames,
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dungeonpermission"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestComponentPersistsRepositoriesAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1", State: "active"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creator := group.Character.(repository.CharacterCreator)
|
||||
if err := creator.CreateCharacter(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001", AccountID: "account-1", Slot: 0, Name: "hero", Level: 90,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{"0:0": {ItemID: 1234, Count: 2}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := group.DungeonPermission.UpsertMax(ctx, "1001", 55, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := component.Snapshot().RecordCount; got != 4 {
|
||||
t.Fatalf("record count = %d, want 4", got)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted, restartedDB := startTestComponent(t, path)
|
||||
defer restartedDB.Close()
|
||||
defer restarted.Stop(ctx)
|
||||
restored, _ := restarted.Group()
|
||||
character, found, err := restored.Character.Load(ctx, "1001")
|
||||
if err != nil || !found || character.Name != "hero" || character.Level != 90 {
|
||||
t.Fatalf("restored character = %+v found=%t err=%v", character, found, err)
|
||||
}
|
||||
inventory, found, err := restored.Inventory.Load(ctx, "1001")
|
||||
if err != nil || !found || inventory.Slots["0:0"].Count != 2 {
|
||||
t.Fatalf("restored inventory = %+v found=%t err=%v", inventory, found, err)
|
||||
}
|
||||
permission, found, err := restored.DungeonPermission.Load(ctx, "1001")
|
||||
if err != nil || !found || len(permission.Entries) != 1 || permission.Entries[0].ClearState != 3 {
|
||||
t.Fatalf("restored permission = %+v found=%t err=%v", permission, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentPersistsCaseSensitiveUnitedFriendsAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
if err := group.UnitedFriend.Save(ctx, repository.UnitedFriendRecord{
|
||||
OwnerName: "Owner",
|
||||
FriendNames: []string{"Friend", "friend"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted, restartedDB := startTestComponent(t, path)
|
||||
defer restartedDB.Close()
|
||||
defer restarted.Stop(ctx)
|
||||
restored, _ := restarted.Group()
|
||||
record, found, err := restored.UnitedFriend.Load(ctx, "Owner")
|
||||
if err != nil || !found || len(record.FriendNames) != 2 ||
|
||||
record.FriendNames[0] != "Friend" || record.FriendNames[1] != "friend" {
|
||||
t.Fatalf("restored united friends=%+v found=%t err=%v", record, found, err)
|
||||
}
|
||||
upper, err := restored.UnitedFriend.ListObservers(ctx, "Friend")
|
||||
if err != nil || len(upper) != 1 || upper[0] != "Owner" {
|
||||
t.Fatalf("uppercase observers=%v err=%v", upper, err)
|
||||
}
|
||||
lower, err := restored.UnitedFriend.ListObservers(ctx, "friend")
|
||||
if err != nil || len(lower) != 1 || lower[0] != "Owner" {
|
||||
t.Fatalf("lowercase observers=%v err=%v", lower, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentPersistsAccountDungeonPermissionOwnerAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
accountKey, err := dungeonpermission.AccountOwnerKey("account-shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, changed, err := group.DungeonPermission.UpsertMax(ctx, accountKey, 700, 4); err != nil || !changed {
|
||||
t.Fatalf("account permission changed=%t err=%v", changed, err)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted, restartedDB := startTestComponent(t, path)
|
||||
defer restartedDB.Close()
|
||||
defer restarted.Stop(ctx)
|
||||
restored, _ := restarted.Group()
|
||||
permission, found, err := restored.DungeonPermission.Load(ctx, accountKey)
|
||||
if err != nil || !found || permission.CharacterID != accountKey ||
|
||||
len(permission.Entries) != 1 || permission.Entries[0].DungeonID != 700 ||
|
||||
permission.Entries[0].ClearState != 4 {
|
||||
t.Fatalf("restored account permission=%+v found=%t err=%v", permission, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteAccountFindIDByLoginUsernameUsesLoginMapping(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
if _, err := database.ExecContext(ctx, `CREATE TABLE dnf_login_accounts (
|
||||
account_id TEXT NOT NULL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO dnf_login_accounts (account_id, username, state) VALUES (?, ?, ?)`, "dnf:1", "account-one", "active"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
finder, ok := group.Account.(repository.LoginAccountUsernameFinder)
|
||||
if !ok {
|
||||
t.Fatal("SQLite account repository has no login username finder")
|
||||
}
|
||||
accountID, found, err := finder.FindAccountIDByLoginUsername(ctx, " ACCOUNT-ONE ")
|
||||
if err != nil || !found || accountID != "dnf:1" {
|
||||
t.Fatalf("FindAccountIDByLoginUsername() account=%q found=%t error=%v", accountID, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentPeriodicAndStopSyncKeepLatestSaveAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
previousInterval := periodicSyncEvery
|
||||
periodicSyncEvery = 10 * time.Millisecond
|
||||
defer func() { periodicSyncEvery = previousInterval }()
|
||||
|
||||
component, database := startTestComponent(t, path)
|
||||
group, ok := component.Group()
|
||||
if !ok {
|
||||
t.Fatal("repository group is unavailable")
|
||||
}
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Name: "latest",
|
||||
Level: 15,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for component.Snapshot().SyncCount == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
periodicSnapshot := component.Snapshot()
|
||||
if periodicSnapshot.SyncCount == 0 || periodicSnapshot.LastSyncAt == "" || periodicSnapshot.LastSyncError != "" {
|
||||
t.Fatalf("periodic sync snapshot = %+v", periodicSnapshot)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stoppedSnapshot := component.Snapshot()
|
||||
if stoppedSnapshot.SyncCount <= periodicSnapshot.SyncCount || stoppedSnapshot.LastSyncError != "" {
|
||||
t.Fatalf("stop sync snapshot = %+v, periodic = %+v", stoppedSnapshot, periodicSnapshot)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if walInfo, err := os.Stat(path + "-wal"); err == nil && walInfo.Size() > 0 {
|
||||
t.Fatalf("WAL still contains %d bytes after stop", walInfo.Size())
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restarted, restartedDB := startTestComponent(t, path)
|
||||
defer restartedDB.Close()
|
||||
defer restarted.Stop(ctx)
|
||||
restored, _ := restarted.Group()
|
||||
character, found, err := restored.Character.Load(ctx, "1001")
|
||||
if err != nil || !found || character.Name != "latest" || character.Level != 15 {
|
||||
t.Fatalf("restored character = %+v found=%t err=%v", character, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinarySaveIsDirtyUntilBackgroundSync(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Name: "async",
|
||||
Level: 11,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !component.Snapshot().Dirty {
|
||||
t.Fatal("ordinary save was not marked dirty")
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = 'character' AND record_key = '1001'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("ordinary save reached SQLite before background sync: count=%d", count)
|
||||
}
|
||||
if err := component.syncRuntime(ctx, checkpointModePassive, sqliteSnapshotSyncSourcePeriodic); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if component.Snapshot().Dirty {
|
||||
t.Fatal("successful background sync left dirty state")
|
||||
}
|
||||
if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = 'character' AND record_key = '1001'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("background sync did not persist ordinary save: count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterAssetsSyncPersistsOnlySelectedCharacterDelta(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
// Ordinary saves are intentionally deferred. They model unrelated scene
|
||||
// state already waiting for SQLite's periodic sync when a pickup arrives.
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Stats: map[string]int64{"gold": 1},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
||||
CharacterID: "unrelated",
|
||||
States: map[int64]repository.QuestState{7: {Status: "active"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scopedAssets, ok := group.CharacterAssets.(repository.CharacterAssetScopedUnitOfWork)
|
||||
if !ok {
|
||||
t.Fatal("SQLite character assets do not expose the scoped pickup transaction")
|
||||
}
|
||||
err := scopedAssets.WithinCharacterAssetsScoped(ctx, "1001", func(
|
||||
_ repository.CharacterRepository,
|
||||
inventories repository.InventoryRepository,
|
||||
_ repository.EquipmentRepository,
|
||||
) error {
|
||||
inventory, found, loadErr := inventories.Load(ctx, "1001")
|
||||
if loadErr != nil || !found {
|
||||
return fmt.Errorf("load selected inventory found=%t: %w", found, loadErr)
|
||||
}
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]repository.ItemStack)
|
||||
}
|
||||
inventory.Slots["0:121"] = repository.ItemStack{ItemID: 3227, Count: 1}
|
||||
return inventories.Save(ctx, inventory)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
kind string
|
||||
key string
|
||||
}{
|
||||
{kind: kindCharacter, key: "1001"},
|
||||
{kind: kindInventory, key: "1001"},
|
||||
} {
|
||||
var count int
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = ? AND record_key = ?`,
|
||||
want.kind,
|
||||
want.key,
|
||||
).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("durable %s %q count=%d err=%v", want.kind, want.key, count, err)
|
||||
}
|
||||
}
|
||||
var unrelatedCount int
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = ? AND record_key = ?`,
|
||||
kindQuest,
|
||||
"unrelated",
|
||||
).Scan(&unrelatedCount); err != nil || unrelatedCount != 0 {
|
||||
t.Fatalf("unrelated async record count=%d err=%v", unrelatedCount, err)
|
||||
}
|
||||
if !component.Snapshot().Dirty {
|
||||
t.Fatal("selected character sync cleared unrelated pending SQLite state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestStateEntryWriterMergesObjectivesWithoutStaleFullRecordOverwrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
||||
CharacterID: "1001",
|
||||
States: map[int64]repository.QuestState{
|
||||
101: {Status: "active", ProgressValue: 3, Extra: map[string]string{"source": "seed-a"}},
|
||||
102: {Status: "active", ProgressValue: 5, Extra: map[string]string{"source": "seed-b"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer, ok := group.Quest.(repository.QuestStateEntryWriter)
|
||||
if !ok {
|
||||
t.Fatal("SQLite quest repository has no single-entry writer")
|
||||
}
|
||||
firstAt := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
||||
secondAt := firstAt.Add(time.Second)
|
||||
if err := writer.SaveQuestStateEntry(ctx, "1001", repository.QuestFieldStates, 101, repository.QuestState{
|
||||
Status: "active", ProgressValue: 2, UpdatedAt: firstAt, Extra: map[string]string{"source": "op33"},
|
||||
}, firstAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.SaveQuestStateEntry(ctx, "1001", repository.QuestFieldStates, 102, repository.QuestState{
|
||||
Status: "active", ProgressValue: 4, UpdatedAt: secondAt, Extra: map[string]string{"source": "drop"},
|
||||
}, secondAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, found, err := group.Quest.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load merged quest found=%t err=%v", found, err)
|
||||
}
|
||||
if got := record.States[101]; got.ProgressValue != 2 || got.Extra["source"] != "op33" {
|
||||
t.Fatalf("quest 101=%+v", got)
|
||||
}
|
||||
if got := record.States[102]; got.ProgressValue != 4 || got.Extra["source"] != "drop" {
|
||||
t.Fatalf("quest 102=%+v", got)
|
||||
}
|
||||
if !record.UpdatedAt.Equal(secondAt) {
|
||||
t.Fatalf("quest updated_at=%s want=%s", record.UpdatedAt, secondAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCriticalUnitOfWorkFlushesPendingAndCurrentStateImmediately(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !component.Snapshot().Dirty {
|
||||
t.Fatal("ordinary inventory seed was not dirty")
|
||||
}
|
||||
if err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
record, found, err := inventory.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9001, Count: 2}}
|
||||
return inventory.Save(ctx, record)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if component.Snapshot().Dirty {
|
||||
t.Fatal("critical unit of work did not flush pending state")
|
||||
}
|
||||
state, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Inventories) != 1 || state.Inventories[0].Slots["0:0"].Count != 2 {
|
||||
t.Fatalf("critical state was not immediately persisted: %+v", state.Inventories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCriticalUnitOfWorkFlushesUnrelatedPendingState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Name: "pending-character",
|
||||
Level: 90,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
record, found, err := inventory.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9001, Count: 1}}
|
||||
return inventory.Save(ctx, record)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Characters) != 1 || state.Characters[0].Name != "pending-character" {
|
||||
t.Fatalf("unrelated pending character was not flushed: %+v", state.Characters)
|
||||
}
|
||||
if len(state.Inventories) != 1 || state.Inventories[0].Slots["0:0"].ItemID != 9001 {
|
||||
t.Fatalf("critical inventory was not flushed: %+v", state.Inventories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCriticalUnitOfWorkAllowsOuterRepositoryReadWithoutSelfDeadlock(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(context.Background())
|
||||
group, _ := component.Group()
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Name: "outer-read",
|
||||
Level: 90,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
// This deliberately uses the outer SQLite wrapper, matching the op19
|
||||
// placement validator that used to re-enter Component.mu forever.
|
||||
character, found, err := group.Character.Load(ctx, "1001")
|
||||
if err != nil || !found || character.Level != 90 {
|
||||
return fmt.Errorf("load outer character found=%t level=%d: %w", found, character.Level, err)
|
||||
}
|
||||
record, found, err := inventory.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load transaction inventory found=%t: %w", found, err)
|
||||
}
|
||||
record.Slots = map[string]repository.ItemStack{"1:34": {ItemID: 112540232, Count: 1}}
|
||||
return inventory.Save(ctx, record)
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("outer repository read self-deadlocked: %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxAssetUnitOfWorkAllowsScopedAccountSaveWithoutSelfDeadlock(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(context.Background())
|
||||
group, _ := component.Group()
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1", State: "active"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "account-1",
|
||||
Name: "mailbox-assets",
|
||||
Level: 90,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- group.MailboxAssets.WithinMailboxAssets(ctx, "1001", "1001", func(
|
||||
accounts repository.AccountRepository,
|
||||
_ repository.AccountInventoryRepository,
|
||||
_ repository.CharacterRepository,
|
||||
inventory repository.InventoryRepository,
|
||||
_ repository.MailboxRepository,
|
||||
) error {
|
||||
// The Seria box is an account-and-character atomic write. It must
|
||||
// save through the repository scoped by this unit of work, rather
|
||||
// than re-enter the outer SQLite Account wrapper and wait on itself.
|
||||
account, found, err := accounts.Load(ctx, "account-1")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load scoped account found=%t: %w", found, err)
|
||||
}
|
||||
account.Metadata = map[string]string{"seria_luck_value": "1"}
|
||||
if err := accounts.Save(ctx, account); err != nil {
|
||||
return fmt.Errorf("save scoped account: %w", err)
|
||||
}
|
||||
record, found, err := inventory.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load transaction inventory found=%t: %w", found, err)
|
||||
}
|
||||
record.Slots = map[string]repository.ItemStack{"0:65": {ItemID: 2600014, Count: 1}}
|
||||
return inventory.Save(ctx, record)
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("scoped account save self-deadlocked: %v", ctx.Err())
|
||||
}
|
||||
account, found, err := group.Account.Load(context.Background(), "account-1")
|
||||
if err != nil || !found || account.Metadata["seria_luck_value"] != "1" {
|
||||
t.Fatalf("saved account found=%t err=%v account=%+v", found, err, account)
|
||||
}
|
||||
bag, found, err := group.Inventory.Load(context.Background(), "1001")
|
||||
if err != nil || !found || bag.Slots["0:65"].ItemID != 2600014 {
|
||||
t.Fatalf("saved inventory found=%t err=%v inventory=%+v", found, err, bag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationContentionHonorsContextDeadline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- component.mutateSync(ctx, func(repository.Group) error {
|
||||
close(entered)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
<-entered
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Millisecond)
|
||||
defer cancel()
|
||||
err := group.Character.Save(waitCtx, repository.CharacterRecord{CharacterID: "1001"})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
close(release)
|
||||
<-done
|
||||
t.Fatalf("contended mutation error=%v, want deadline exceeded", err)
|
||||
}
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentUnitOfWorkRollbackDoesNotReachSQLite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("reject mutation")
|
||||
err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
record, _, loadErr := inventory.Load(ctx, "1001")
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9999, Count: 1}}
|
||||
if saveErr := inventory.Save(ctx, record); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return wantErr
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("transaction error = %v, want %v", err, wantErr)
|
||||
}
|
||||
record, found, err := group.Inventory.Load(ctx, "1001")
|
||||
if err != nil || !found || len(record.Slots) != 0 {
|
||||
t.Fatalf("rolled-back inventory = %+v found=%t err=%v", record, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentRejectsCorruptRecordChecksum(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
component, database := startTestComponent(t, path)
|
||||
group, _ := component.Group()
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := component.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `UPDATE dnf_repository_records SET checksum = 'bad'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrupt := NewComponent(database, path)
|
||||
if err := corrupt.Start(ctx); err == nil {
|
||||
t.Fatal("corrupt checksum was accepted")
|
||||
}
|
||||
database.Close()
|
||||
}
|
||||
|
||||
func TestReplaceStateValidatesAndReplacesAllRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "dnf90.db")
|
||||
database, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.SetMaxOpenConns(1)
|
||||
database.SetMaxIdleConns(1)
|
||||
defer database.Close()
|
||||
|
||||
first := memory.State{
|
||||
Version: memory.StateVersion,
|
||||
Accounts: []repository.AccountRecord{{
|
||||
AccountID: "old-account",
|
||||
}},
|
||||
}
|
||||
if err := ReplaceState(ctx, database, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := memory.State{
|
||||
Version: memory.StateVersion,
|
||||
Accounts: []repository.AccountRecord{{
|
||||
AccountID: "account-1",
|
||||
}},
|
||||
Characters: []repository.CharacterRecord{{
|
||||
CharacterID: "1",
|
||||
AccountID: "account-1",
|
||||
Slot: 0,
|
||||
Name: "Test",
|
||||
Level: 90,
|
||||
}},
|
||||
}
|
||||
if err := ReplaceState(ctx, database, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
component := NewComponent(database, path)
|
||||
if err := component.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer component.Stop(ctx)
|
||||
if got := component.Snapshot().RecordCount; got != 2 {
|
||||
t.Fatalf("record count = %d, want 2", got)
|
||||
}
|
||||
group, _ := component.Group()
|
||||
if _, found, err := group.Account.Load(ctx, "old-account"); err != nil || found {
|
||||
t.Fatalf("old account found=%t err=%v", found, err)
|
||||
}
|
||||
character, found, err := group.Character.Load(ctx, "1")
|
||||
if err != nil || !found || character.Name != "Test" || character.Level != 90 {
|
||||
t.Fatalf("character = %+v found=%t err=%v", character, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func startTestComponent(t *testing.T, path string) (*Component, *sql.DB) {
|
||||
t.Helper()
|
||||
database, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database.SetMaxOpenConns(1)
|
||||
database.SetMaxIdleConns(1)
|
||||
component := NewComponent(database, path)
|
||||
if err := component.Start(context.Background()); err != nil {
|
||||
database.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return component, database
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package sqlite
|
||||
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS dnf_repository_meta (
|
||||
meta_key TEXT PRIMARY KEY,
|
||||
meta_value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dnf_repository_records (
|
||||
record_kind TEXT NOT NULL,
|
||||
record_key TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (record_kind, record_key)
|
||||
);
|
||||
`
|
||||
@@ -0,0 +1,449 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
// scopedRecordRef identifies one aggregate record that a synchronous unit of
|
||||
// work is allowed to mutate. Keeping this list explicit prevents a small item
|
||||
// transaction from cloning, checksumming and writing the complete runtime
|
||||
// snapshot while it owns SQLite's mutation gate.
|
||||
type scopedRecordRef struct {
|
||||
kind string
|
||||
key string
|
||||
}
|
||||
|
||||
func (c *Component) mutateCharacterItemsScopedSync(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
apply func(repository.InventoryRepository, repository.EquipmentRepository) error,
|
||||
) error {
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" || apply == nil {
|
||||
return repository.ErrCharacterItemTransactionUnavailable
|
||||
}
|
||||
return c.mutateRecordScopeSync(ctx, []scopedRecordRef{
|
||||
{kind: kindInventory, key: characterID},
|
||||
{kind: kindEquipment, key: characterID},
|
||||
}, func(group repository.Group) error {
|
||||
return group.CharacterItems.WithinCharacterItems(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Component) mutateCharacterPetsScopedSync(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
apply func(repository.InventoryRepository, repository.EquipmentRepository, repository.PetRepository) error,
|
||||
) error {
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" || apply == nil {
|
||||
return repository.ErrCharacterPetTransactionUnavailable
|
||||
}
|
||||
return c.mutateRecordScopeSync(ctx, []scopedRecordRef{
|
||||
{kind: kindInventory, key: characterID},
|
||||
{kind: kindEquipment, key: characterID},
|
||||
{kind: kindPet, key: characterID},
|
||||
}, func(group repository.Group) error {
|
||||
return group.CharacterPets.WithinCharacterPets(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Component) mutateRentalAssetsScopedSync(
|
||||
ctx context.Context,
|
||||
accountID string,
|
||||
characterID string,
|
||||
apply func(repository.AccountRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error,
|
||||
) error {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if accountID == "" || characterID == "" || apply == nil {
|
||||
return repository.ErrRentalAssetTransactionUnavailable
|
||||
}
|
||||
return c.mutateRecordScopeSync(ctx, []scopedRecordRef{
|
||||
{kind: kindAccount, key: accountID},
|
||||
{kind: kindCharacter, key: characterID},
|
||||
{kind: kindInventory, key: characterID},
|
||||
{kind: kindEquipment, key: characterID},
|
||||
}, func(group repository.Group) error {
|
||||
return group.RentalAssets.WithinRentalAssets(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Component) mutateRecordScopeSync(
|
||||
ctx context.Context,
|
||||
refs []scopedRecordRef,
|
||||
apply func(repository.Group) error,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
refs, err := normalizeScopedRecordRefs(refs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apply == nil {
|
||||
return fmt.Errorf("sqlite scoped mutation callback is nil")
|
||||
}
|
||||
if err := c.acquireMutation(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.releaseMutation()
|
||||
|
||||
c.mu.RLock()
|
||||
if !c.started || c.runtime == nil {
|
||||
c.mu.RUnlock()
|
||||
return ErrDatabaseRequired
|
||||
}
|
||||
group := c.runtime.Group()
|
||||
persistedBefore, err := selectScopedState(c.persisted, refs)
|
||||
dirtyBefore := c.dirty
|
||||
c.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtimeBefore, err := loadScopedRuntimeState(ctx, group, refs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := apply(group); err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeAfter, err := loadScopedRuntimeState(ctx, group, refs)
|
||||
if err != nil {
|
||||
return errors.Join(err, restoreScopedRuntimeState(group, refs, runtimeBefore))
|
||||
}
|
||||
if err := persistScopedStateTransition(ctx, c.db, persistedBefore, runtimeAfter); err != nil {
|
||||
return errors.Join(err, restoreScopedRuntimeState(group, refs, runtimeBefore))
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
replaceScopedState(&c.persisted, refs, runtimeAfter)
|
||||
// An earlier asynchronous mutation may still own an unrelated dirty
|
||||
// record. Never clear that signal merely because this scope is durable.
|
||||
c.dirty = dirtyBefore
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeScopedRecordRefs(refs []scopedRecordRef) ([]scopedRecordRef, error) {
|
||||
out := make([]scopedRecordRef, 0, len(refs))
|
||||
seen := make(map[string]struct{}, len(refs))
|
||||
for _, ref := range refs {
|
||||
ref.kind = strings.TrimSpace(ref.kind)
|
||||
ref.key = strings.TrimSpace(ref.key)
|
||||
if ref.key == "" {
|
||||
return nil, fmt.Errorf("sqlite scoped mutation record key is empty")
|
||||
}
|
||||
switch ref.kind {
|
||||
case kindAccount, kindCharacter, kindInventory, kindEquipment, kindPet:
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported sqlite scoped mutation kind %q", ref.kind)
|
||||
}
|
||||
identity := ref.kind + "\x00" + ref.key
|
||||
if _, exists := seen[identity]; exists {
|
||||
continue
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
out = append(out, ref)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("sqlite scoped mutation records are empty")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadScopedRuntimeState(ctx context.Context, group repository.Group, refs []scopedRecordRef) (memory.State, error) {
|
||||
state := memory.State{Version: memory.StateVersion}
|
||||
for _, ref := range refs {
|
||||
switch ref.kind {
|
||||
case kindAccount:
|
||||
record, found, err := group.Account.Load(ctx, ref.key)
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
if found {
|
||||
state.Accounts = append(state.Accounts, repository.CloneAccount(record))
|
||||
}
|
||||
case kindCharacter:
|
||||
record, found, err := group.Character.Load(ctx, ref.key)
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
if found {
|
||||
state.Characters = append(state.Characters, repository.CloneCharacter(record))
|
||||
}
|
||||
case kindInventory:
|
||||
record, found, err := group.Inventory.Load(ctx, ref.key)
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
if found {
|
||||
state.Inventories = append(state.Inventories, repository.CloneInventory(record))
|
||||
}
|
||||
case kindEquipment:
|
||||
record, found, err := group.Equipment.Load(ctx, ref.key)
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
if found {
|
||||
state.Equipment = append(state.Equipment, repository.CloneEquipment(record))
|
||||
}
|
||||
case kindPet:
|
||||
record, found, err := group.Pet.Load(ctx, ref.key)
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
if found {
|
||||
state.Pets = append(state.Pets, repository.ClonePet(record))
|
||||
}
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func selectScopedState(state memory.State, refs []scopedRecordRef) (memory.State, error) {
|
||||
// The persisted snapshot is immutable while the mutation gate is held. A
|
||||
// tiny memory runtime gives us the same clone and key-validation behavior
|
||||
// as live repositories without serializing unrelated records.
|
||||
temporary, err := memory.NewRuntimeFromState(context.Background(), stateForScopedRefs(state, refs))
|
||||
if err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
return loadScopedRuntimeState(context.Background(), temporary.Group(), refs)
|
||||
}
|
||||
|
||||
func stateForScopedRefs(state memory.State, refs []scopedRecordRef) memory.State {
|
||||
out := memory.State{Version: memory.StateVersion}
|
||||
for _, ref := range refs {
|
||||
switch ref.kind {
|
||||
case kindAccount:
|
||||
for _, record := range state.Accounts {
|
||||
if record.AccountID == ref.key {
|
||||
out.Accounts = append(out.Accounts, repository.CloneAccount(record))
|
||||
break
|
||||
}
|
||||
}
|
||||
case kindCharacter:
|
||||
for _, record := range state.Characters {
|
||||
if record.CharacterID == ref.key {
|
||||
out.Characters = append(out.Characters, repository.CloneCharacter(record))
|
||||
break
|
||||
}
|
||||
}
|
||||
case kindInventory:
|
||||
for _, record := range state.Inventories {
|
||||
if record.CharacterID == ref.key {
|
||||
out.Inventories = append(out.Inventories, repository.CloneInventory(record))
|
||||
break
|
||||
}
|
||||
}
|
||||
case kindEquipment:
|
||||
for _, record := range state.Equipment {
|
||||
if record.CharacterID == ref.key {
|
||||
out.Equipment = append(out.Equipment, repository.CloneEquipment(record))
|
||||
break
|
||||
}
|
||||
}
|
||||
case kindPet:
|
||||
for _, record := range state.Pets {
|
||||
if record.CharacterID == ref.key {
|
||||
out.Pets = append(out.Pets, repository.ClonePet(record))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func persistScopedStateTransition(ctx context.Context, database *sql.DB, before, after memory.State) error {
|
||||
oldRecords, err := stateRecords(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRecords, err := stateRecords(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return persistRecordTransition(ctx, database, oldRecords, newRecords)
|
||||
}
|
||||
|
||||
func replaceScopedState(target *memory.State, refs []scopedRecordRef, replacement memory.State) {
|
||||
if target == nil {
|
||||
return
|
||||
}
|
||||
for _, ref := range refs {
|
||||
switch ref.kind {
|
||||
case kindAccount:
|
||||
target.Accounts = removeAccountStateRecord(target.Accounts, ref.key)
|
||||
case kindCharacter:
|
||||
target.Characters = removeCharacterStateRecord(target.Characters, ref.key)
|
||||
case kindInventory:
|
||||
target.Inventories = removeInventoryStateRecord(target.Inventories, ref.key)
|
||||
case kindEquipment:
|
||||
target.Equipment = removeEquipmentStateRecord(target.Equipment, ref.key)
|
||||
case kindPet:
|
||||
target.Pets = removePetStateRecord(target.Pets, ref.key)
|
||||
}
|
||||
}
|
||||
target.Accounts = append(target.Accounts, replacement.Accounts...)
|
||||
target.Characters = append(target.Characters, replacement.Characters...)
|
||||
target.Inventories = append(target.Inventories, replacement.Inventories...)
|
||||
target.Equipment = append(target.Equipment, replacement.Equipment...)
|
||||
target.Pets = append(target.Pets, replacement.Pets...)
|
||||
}
|
||||
|
||||
func restoreScopedRuntimeState(group repository.Group, refs []scopedRecordRef, state memory.State) error {
|
||||
var restoreErrors []error
|
||||
for _, ref := range refs {
|
||||
switch ref.kind {
|
||||
case kindAccount:
|
||||
record, found := findAccountStateRecord(state.Accounts, ref.key)
|
||||
restoreErrors = appendRestoreError(restoreErrors, restoreStoreRecord(group.Account, ref.key, record, found))
|
||||
case kindCharacter:
|
||||
record, found := findCharacterStateRecord(state.Characters, ref.key)
|
||||
restoreErrors = appendRestoreError(restoreErrors, restoreStoreRecord(group.Character, ref.key, record, found))
|
||||
case kindInventory:
|
||||
record, found := findInventoryStateRecord(state.Inventories, ref.key)
|
||||
restoreErrors = appendRestoreError(restoreErrors, restoreStoreRecord(group.Inventory, ref.key, record, found))
|
||||
case kindEquipment:
|
||||
record, found := findEquipmentStateRecord(state.Equipment, ref.key)
|
||||
restoreErrors = appendRestoreError(restoreErrors, restoreStoreRecord(group.Equipment, ref.key, record, found))
|
||||
case kindPet:
|
||||
record, found := findPetStateRecord(state.Pets, ref.key)
|
||||
restoreErrors = appendRestoreError(restoreErrors, restoreStoreRecord(group.Pet, ref.key, record, found))
|
||||
}
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
|
||||
type deletableStore[T any] interface {
|
||||
Save(context.Context, T) error
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
func restoreStoreRecord[T any](store interface {
|
||||
Save(context.Context, T) error
|
||||
}, key string, record T, found bool) error {
|
||||
if found {
|
||||
return store.Save(context.Background(), record)
|
||||
}
|
||||
deletable, ok := store.(deletableStore[T])
|
||||
if !ok {
|
||||
return fmt.Errorf("sqlite scoped rollback cannot delete %q", key)
|
||||
}
|
||||
return deletable.Delete(context.Background(), key)
|
||||
}
|
||||
|
||||
func appendRestoreError(errs []error, err error) []error {
|
||||
if err != nil {
|
||||
return append(errs, err)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func findAccountStateRecord(records []repository.AccountRecord, key string) (repository.AccountRecord, bool) {
|
||||
for _, record := range records {
|
||||
if record.AccountID == key {
|
||||
return repository.CloneAccount(record), true
|
||||
}
|
||||
}
|
||||
return repository.AccountRecord{}, false
|
||||
}
|
||||
|
||||
func findCharacterStateRecord(records []repository.CharacterRecord, key string) (repository.CharacterRecord, bool) {
|
||||
for _, record := range records {
|
||||
if record.CharacterID == key {
|
||||
return repository.CloneCharacter(record), true
|
||||
}
|
||||
}
|
||||
return repository.CharacterRecord{}, false
|
||||
}
|
||||
|
||||
func findInventoryStateRecord(records []repository.InventoryRecord, key string) (repository.InventoryRecord, bool) {
|
||||
for _, record := range records {
|
||||
if record.CharacterID == key {
|
||||
return repository.CloneInventory(record), true
|
||||
}
|
||||
}
|
||||
return repository.InventoryRecord{}, false
|
||||
}
|
||||
|
||||
func findEquipmentStateRecord(records []repository.EquipmentRecord, key string) (repository.EquipmentRecord, bool) {
|
||||
for _, record := range records {
|
||||
if record.CharacterID == key {
|
||||
return repository.CloneEquipment(record), true
|
||||
}
|
||||
}
|
||||
return repository.EquipmentRecord{}, false
|
||||
}
|
||||
|
||||
func findPetStateRecord(records []repository.PetRecord, key string) (repository.PetRecord, bool) {
|
||||
for _, record := range records {
|
||||
if record.CharacterID == key {
|
||||
return repository.ClonePet(record), true
|
||||
}
|
||||
}
|
||||
return repository.PetRecord{}, false
|
||||
}
|
||||
|
||||
func removeAccountStateRecord(records []repository.AccountRecord, key string) []repository.AccountRecord {
|
||||
out := records[:0]
|
||||
for _, record := range records {
|
||||
if record.AccountID != key {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeCharacterStateRecord(records []repository.CharacterRecord, key string) []repository.CharacterRecord {
|
||||
out := records[:0]
|
||||
for _, record := range records {
|
||||
if record.CharacterID != key {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeInventoryStateRecord(records []repository.InventoryRecord, key string) []repository.InventoryRecord {
|
||||
out := records[:0]
|
||||
for _, record := range records {
|
||||
if record.CharacterID != key {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeEquipmentStateRecord(records []repository.EquipmentRecord, key string) []repository.EquipmentRecord {
|
||||
out := records[:0]
|
||||
for _, record := range records {
|
||||
if record.CharacterID != key {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removePetStateRecord(records []repository.PetRecord, key string) []repository.PetRecord {
|
||||
out := records[:0]
|
||||
for _, record := range records {
|
||||
if record.CharacterID != key {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestCharacterSettlementAtomicallyPersistsQuestAndInventory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
component, database := startTestComponent(t, filepath.Join(t.TempDir(), "dnf90.db"))
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{CharacterID: "1001", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001", Slots: map[string]repository.ItemStack{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.CharacterSettlement.WithinCharacterSettlement(ctx, "1001", func(tx repository.Group) error {
|
||||
inventory, found, err := tx.Inventory.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]repository.ItemStack)
|
||||
}
|
||||
inventory.Slots["0:177"] = repository.ItemStack{ItemID: 1001, Count: 1}
|
||||
if err := repository.SaveInventoryFields(ctx, tx.Inventory, inventory, repository.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.SaveQuestFields(ctx, tx.Quest, repository.QuestRecord{
|
||||
CharacterID: "1001",
|
||||
States: map[int64]repository.QuestState{1680: {Status: "active", ProgressValue: 1}},
|
||||
}, repository.QuestFieldStates)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persisted, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(persisted.Inventories) != 1 || persisted.Inventories[0].Slots["0:177"].ItemID != 1001 ||
|
||||
len(persisted.Quests) != 1 || persisted.Quests[0].States[1680].Status != "active" {
|
||||
t.Fatalf("SQLite atomic quest/inventory state inventories=%+v quests=%+v", persisted.Inventories, persisted.Quests)
|
||||
}
|
||||
|
||||
forced := errors.New("forced quest accept rollback")
|
||||
err = group.CharacterSettlement.WithinCharacterSettlement(ctx, "1001", func(tx repository.Group) error {
|
||||
inventory, _, err := tx.Inventory.Load(ctx, "1001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stack := inventory.Slots["0:177"]
|
||||
stack.Count = 2
|
||||
inventory.Slots["0:177"] = stack
|
||||
if err := repository.SaveInventoryFields(ctx, tx.Inventory, inventory, repository.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
return forced
|
||||
})
|
||||
if !errors.Is(err, forced) {
|
||||
t.Fatalf("SQLite rollback error=%v", err)
|
||||
}
|
||||
inventory, found, err := group.Inventory.Load(ctx, "1001")
|
||||
if err != nil || !found || inventory.Slots["0:177"].Count != 1 {
|
||||
t.Fatalf("SQLite rollback inventory=%+v found=%t err=%v", inventory, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterItemsScopedPersistsOnlyOwnedRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
component, database := startTestComponent(t, filepath.Join(t.TempDir(), "dnf90.db"))
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{"0:10": {ItemID: 8474, Count: 2}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
||||
CharacterID: "unrelated",
|
||||
States: map[int64]repository.QuestState{7: {Status: "active"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scopedItems, ok := group.CharacterItems.(repository.CharacterItemScopedUnitOfWork)
|
||||
if !ok {
|
||||
t.Fatal("SQLite character items do not expose the scoped consumable transaction")
|
||||
}
|
||||
if err := scopedItems.WithinCharacterItemsScoped(ctx, "1001", func(inventories repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
record, found, err := inventories.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
stack := record.Slots["0:10"]
|
||||
stack.Count--
|
||||
record.Slots["0:10"] = stack
|
||||
return inventories.Save(ctx, record)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.Inventories) != 1 || state.Inventories[0].Slots["0:10"].Count != 1 {
|
||||
t.Fatalf("scoped inventory was not durable: %+v", state.Inventories)
|
||||
}
|
||||
if len(state.Quests) != 0 {
|
||||
t.Fatalf("scoped consumable flushed unrelated quest state: %+v", state.Quests)
|
||||
}
|
||||
if !component.Snapshot().Dirty {
|
||||
t.Fatal("scoped consumable cleared the unrelated pending-write signal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterItemsScopedPersistenceFailureRestoresOnlyItsRuntimeRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
component, database := startTestComponent(t, filepath.Join(t.TempDir(), "dnf90.db"))
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{"0:10": {ItemID: 8474, Count: 2}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(repository.InventoryRepository, repository.EquipmentRepository) error {
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
CREATE TRIGGER reject_scoped_inventory_update
|
||||
BEFORE UPDATE ON dnf_repository_records
|
||||
WHEN NEW.record_kind = 'inventory' AND NEW.record_key = '1001'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced scoped inventory failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scopedItems := group.CharacterItems.(repository.CharacterItemScopedUnitOfWork)
|
||||
err := scopedItems.WithinCharacterItemsScoped(ctx, "1001", func(inventories repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
||||
record, found, err := inventories.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
stack := record.Slots["0:10"]
|
||||
stack.Count = 1
|
||||
record.Slots["0:10"] = stack
|
||||
return inventories.Save(ctx, record)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("scoped persistence failure was accepted")
|
||||
}
|
||||
|
||||
runtimeRecord, found, loadErr := group.Inventory.Load(ctx, "1001")
|
||||
if loadErr != nil || !found || runtimeRecord.Slots["0:10"].Count != 2 {
|
||||
t.Fatalf("runtime rollback found=%t err=%v record=%+v", found, loadErr, runtimeRecord)
|
||||
}
|
||||
durable, loadErr := loadState(ctx, database)
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
if len(durable.Inventories) != 1 || durable.Inventories[0].Slots["0:10"].Count != 2 {
|
||||
t.Fatalf("durable inventory changed after failed scope: %+v", durable.Inventories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalAssetsScopedPersistsOnlyOwnedRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
component, database := startTestComponent(t, filepath.Join(t.TempDir(), "dnf90.db"))
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{
|
||||
AccountID: "dnf:1",
|
||||
Metadata: map[string]string{"rental_lucky_stars": "10"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "dnf:1",
|
||||
Stats: map[string]int64{"gold": 100},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{"0:0": {ItemID: 700, Count: 1}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Equipment.Save(ctx, repository.EquipmentRecord{
|
||||
CharacterID: "1001",
|
||||
Entries: map[string]repository.EquipmentEntry{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
||||
CharacterID: "unrelated",
|
||||
States: map[int64]repository.QuestState{7: {Status: "active"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := group.RentalAssets.(repository.RentalAssetScopedUnitOfWork); !ok {
|
||||
t.Fatal("SQLite rental assets do not expose the record-scoped transaction")
|
||||
}
|
||||
if err := repository.WithinRentalAssetsScoped(ctx, group.RentalAssets, "dnf:1", "1001", func(
|
||||
accounts repository.AccountRepository,
|
||||
characters repository.CharacterRepository,
|
||||
inventories repository.InventoryRepository,
|
||||
equipment repository.EquipmentRepository,
|
||||
) error {
|
||||
account, found, err := accounts.Load(ctx, "dnf:1")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load account found=%t: %w", found, err)
|
||||
}
|
||||
character, found, err := characters.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load character found=%t: %w", found, err)
|
||||
}
|
||||
inventory, found, err := inventories.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
||||
}
|
||||
worn, found, err := equipment.Load(ctx, "1001")
|
||||
if err != nil || !found {
|
||||
return fmt.Errorf("load equipment found=%t: %w", found, err)
|
||||
}
|
||||
if worn.Entries == nil {
|
||||
worn.Entries = make(map[string]repository.EquipmentEntry)
|
||||
}
|
||||
account.Metadata["rental_lucky_stars"] = "6"
|
||||
character.Stats["gold"] = 80
|
||||
inventory.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, inventories, inventory, repository.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.SaveEquipmentFields(ctx, equipment, worn, repository.EquipmentFieldEntries)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
durable, err := loadState(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(durable.Accounts) != 1 || durable.Accounts[0].Metadata["rental_lucky_stars"] != "6" ||
|
||||
len(durable.Characters) != 1 || durable.Characters[0].Stats["gold"] != 80 ||
|
||||
len(durable.Inventories) != 1 || durable.Inventories[0].Slots["0:1"].ItemID != 9001 ||
|
||||
len(durable.Equipment) != 1 || durable.Equipment[0].Entries["11"].ItemID != 9001 {
|
||||
t.Fatalf("scoped rental state was not durable: %+v", durable)
|
||||
}
|
||||
if len(durable.Quests) != 0 {
|
||||
t.Fatalf("scoped rental transaction flushed unrelated quest state: %+v", durable.Quests)
|
||||
}
|
||||
if !component.Snapshot().Dirty {
|
||||
t.Fatal("scoped rental transaction cleared the unrelated pending-write signal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRentalAssetsScopedPersistenceFailureRollsBackFourRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
component, database := startTestComponent(t, filepath.Join(t.TempDir(), "dnf90.db"))
|
||||
defer database.Close()
|
||||
defer component.Stop(ctx)
|
||||
group, _ := component.Group()
|
||||
|
||||
if err := group.Account.Save(ctx, repository.AccountRecord{
|
||||
AccountID: "dnf:1",
|
||||
Metadata: map[string]string{"rental_lucky_stars": "10"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
||||
CharacterID: "1001",
|
||||
AccountID: "dnf:1",
|
||||
Stats: map[string]int64{"gold": 100},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
||||
CharacterID: "1001",
|
||||
Slots: map[string]repository.ItemStack{"0:0": {ItemID: 700, Count: 1}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := group.Equipment.Save(ctx, repository.EquipmentRecord{
|
||||
CharacterID: "1001",
|
||||
Entries: map[string]repository.EquipmentEntry{},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.WithinRentalAssetsScoped(ctx, group.RentalAssets, "dnf:1", "1001", func(
|
||||
repository.AccountRepository,
|
||||
repository.CharacterRepository,
|
||||
repository.InventoryRepository,
|
||||
repository.EquipmentRepository,
|
||||
) error {
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
CREATE TRIGGER reject_scoped_rental_inventory_update
|
||||
BEFORE UPDATE ON dnf_repository_records
|
||||
WHEN NEW.record_kind = 'inventory' AND NEW.record_key = '1001'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced scoped rental failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := repository.WithinRentalAssetsScoped(ctx, group.RentalAssets, "dnf:1", "1001", func(
|
||||
accounts repository.AccountRepository,
|
||||
characters repository.CharacterRepository,
|
||||
inventories repository.InventoryRepository,
|
||||
equipment repository.EquipmentRepository,
|
||||
) error {
|
||||
account, _, err := accounts.Load(ctx, "dnf:1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
character, _, err := characters.Load(ctx, "1001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventory, _, err := inventories.Load(ctx, "1001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
worn, _, err := equipment.Load(ctx, "1001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if worn.Entries == nil {
|
||||
worn.Entries = make(map[string]repository.EquipmentEntry)
|
||||
}
|
||||
account.Metadata["rental_lucky_stars"] = "0"
|
||||
character.Stats["gold"] = 0
|
||||
inventory.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 := inventories.Save(ctx, inventory); err != nil {
|
||||
return err
|
||||
}
|
||||
return equipment.Save(ctx, worn)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("scoped rental persistence failure was accepted")
|
||||
}
|
||||
|
||||
account, accountFound, accountErr := group.Account.Load(ctx, "dnf:1")
|
||||
character, characterFound, characterErr := group.Character.Load(ctx, "1001")
|
||||
inventory, inventoryFound, inventoryErr := group.Inventory.Load(ctx, "1001")
|
||||
worn, equipmentFound, equipmentErr := group.Equipment.Load(ctx, "1001")
|
||||
if accountErr != nil || characterErr != nil || inventoryErr != nil || equipmentErr != nil ||
|
||||
!accountFound || !characterFound || !inventoryFound || !equipmentFound ||
|
||||
account.Metadata["rental_lucky_stars"] != "10" || character.Stats["gold"] != 100 ||
|
||||
len(inventory.Slots) != 1 || inventory.Slots["0:0"].ItemID != 700 || len(worn.Entries) != 0 {
|
||||
t.Fatalf("runtime rollback account=%+v character=%+v inventory=%+v equipment=%+v errors=%v/%v/%v/%v",
|
||||
account, character, inventory, worn, accountErr, characterErr, inventoryErr, equipmentErr)
|
||||
}
|
||||
durable, loadErr := loadState(ctx, database)
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
if len(durable.Accounts) != 1 || durable.Accounts[0].Metadata["rental_lucky_stars"] != "10" ||
|
||||
len(durable.Characters) != 1 || durable.Characters[0].Stats["gold"] != 100 ||
|
||||
len(durable.Inventories) != 1 || len(durable.Inventories[0].Slots) != 1 ||
|
||||
durable.Inventories[0].Slots["0:0"].ItemID != 700 || len(durable.Equipment) != 1 ||
|
||||
len(durable.Equipment[0].Entries) != 0 {
|
||||
t.Fatalf("durable rental records changed after failed scope: %+v", durable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
kindAccount = "account"
|
||||
kindAccountInventory = "account_inventory"
|
||||
kindCharacter = "character"
|
||||
kindInventory = "inventory"
|
||||
kindEquipment = "equipment"
|
||||
kindPet = "pet"
|
||||
kindQuest = "quest"
|
||||
kindSkill = "skill"
|
||||
kindDungeonPermission = "dungeon_permission"
|
||||
kindHellPartyPermission = "hell_party_permission"
|
||||
kindPacketTemplate = "packet_template"
|
||||
kindSettings = "settings"
|
||||
kindMailbox = "mailbox"
|
||||
kindEpicBook = "epic_book"
|
||||
kindUnitedFriend = "united_friend"
|
||||
)
|
||||
|
||||
type persistedRecord struct {
|
||||
kind string
|
||||
key string
|
||||
payload []byte
|
||||
checksum string
|
||||
}
|
||||
|
||||
func stateRecords(state memory.State) (map[string]persistedRecord, error) {
|
||||
out := make(map[string]persistedRecord)
|
||||
add := func(kind, key string, value any) error {
|
||||
record, err := makePersistedRecord(kind, key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[record.kind+"\x00"+record.key] = record
|
||||
return nil
|
||||
}
|
||||
for _, record := range state.Accounts {
|
||||
if err := add(kindAccount, record.AccountID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.AccountInventories {
|
||||
if err := add(kindAccountInventory, record.AccountID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Characters {
|
||||
if err := add(kindCharacter, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Inventories {
|
||||
if err := add(kindInventory, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Equipment {
|
||||
if err := add(kindEquipment, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Pets {
|
||||
if err := add(kindPet, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Quests {
|
||||
if err := add(kindQuest, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Skills {
|
||||
if err := add(kindSkill, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.DungeonPermissions {
|
||||
if err := add(kindDungeonPermission, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.HellPartyPermissions {
|
||||
if err := add(kindHellPartyPermission, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.PacketTemplates {
|
||||
if err := add(kindPacketTemplate, record.TemplateID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Settings {
|
||||
if err := add(kindSettings, record.Scope, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.Mailboxes {
|
||||
if err := add(kindMailbox, record.CharacterID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.EpicBooks {
|
||||
if err := add(kindEpicBook, record.AccountID, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, record := range state.UnitedFriends {
|
||||
if err := add(kindUnitedFriend, record.OwnerName, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func makePersistedRecord(kind, key string, value any) (persistedRecord, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return persistedRecord{}, fmt.Errorf("sqlite repository %s record key is empty", kind)
|
||||
}
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return persistedRecord{}, fmt.Errorf("marshal sqlite repository %s %q: %w", kind, key, err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return persistedRecord{
|
||||
kind: kind,
|
||||
key: key,
|
||||
payload: payload,
|
||||
checksum: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadState(ctx context.Context, database *sql.DB) (memory.State, error) {
|
||||
state := memory.State{Version: memory.StateVersion}
|
||||
var versionText string
|
||||
err := database.QueryRowContext(ctx, `SELECT meta_value FROM dnf_repository_meta WHERE meta_key = 'state_version'`).Scan(&versionText)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return memory.State{}, fmt.Errorf("read sqlite repository state version: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
version, parseErr := strconv.Atoi(versionText)
|
||||
if parseErr != nil || version != memory.StateVersion {
|
||||
return memory.State{}, fmt.Errorf("unsupported sqlite repository state version %q", versionText)
|
||||
}
|
||||
state.Version = version
|
||||
}
|
||||
rows, err := database.QueryContext(ctx, `SELECT record_kind, record_key, payload, checksum FROM dnf_repository_records ORDER BY record_kind, record_key`)
|
||||
if err != nil {
|
||||
return memory.State{}, fmt.Errorf("query sqlite repository records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var kind, key, wantChecksum string
|
||||
var payload []byte
|
||||
if err := rows.Scan(&kind, &key, &payload, &wantChecksum); err != nil {
|
||||
return memory.State{}, fmt.Errorf("scan sqlite repository record: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, wantChecksum) {
|
||||
return memory.State{}, fmt.Errorf("sqlite repository checksum mismatch for %s %q", kind, key)
|
||||
}
|
||||
if err := appendStateRecord(&state, kind, key, payload); err != nil {
|
||||
return memory.State{}, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return memory.State{}, fmt.Errorf("iterate sqlite repository records: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func appendStateRecord(state *memory.State, kind, key string, payload []byte) error {
|
||||
decode := func(target any) error {
|
||||
if err := json.Unmarshal(payload, target); err != nil {
|
||||
return fmt.Errorf("decode sqlite repository %s %q: %w", kind, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case kindAccount:
|
||||
var record repository.AccountRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Accounts = append(state.Accounts, record)
|
||||
case kindAccountInventory:
|
||||
var record repository.AccountInventoryRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.AccountInventories = append(state.AccountInventories, record)
|
||||
case kindCharacter:
|
||||
var record repository.CharacterRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Characters = append(state.Characters, record)
|
||||
case kindInventory:
|
||||
var record repository.InventoryRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Inventories = append(state.Inventories, record)
|
||||
case kindEquipment:
|
||||
var record repository.EquipmentRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Equipment = append(state.Equipment, record)
|
||||
case kindPet:
|
||||
var record repository.PetRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Pets = append(state.Pets, record)
|
||||
case kindQuest:
|
||||
var record repository.QuestRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Quests = append(state.Quests, record)
|
||||
case kindSkill:
|
||||
var record repository.SkillRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Skills = append(state.Skills, record)
|
||||
case kindDungeonPermission:
|
||||
var record repository.DungeonPermissionRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.DungeonPermissions = append(state.DungeonPermissions, record)
|
||||
case kindHellPartyPermission:
|
||||
var record repository.HellPartyPermissionRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.HellPartyPermissions = append(state.HellPartyPermissions, record)
|
||||
case kindPacketTemplate:
|
||||
var record repository.PacketTemplateRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.PacketTemplates = append(state.PacketTemplates, record)
|
||||
case kindSettings:
|
||||
var record repository.SettingsRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Settings = append(state.Settings, record)
|
||||
case kindMailbox:
|
||||
var record repository.MailboxRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.Mailboxes = append(state.Mailboxes, record)
|
||||
case kindEpicBook:
|
||||
var record repository.EpicBookRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.EpicBooks = append(state.EpicBooks, record)
|
||||
case kindUnitedFriend:
|
||||
var record repository.UnitedFriendRecord
|
||||
if err := decode(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
state.UnitedFriends = append(state.UnitedFriends, record)
|
||||
default:
|
||||
return fmt.Errorf("unsupported sqlite repository record kind %q", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistTransition(ctx context.Context, database *sql.DB, before, after memory.State) error {
|
||||
oldRecords, err := stateRecords(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRecords, err := stateRecords(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return persistRecordTransition(ctx, database, oldRecords, newRecords)
|
||||
}
|
||||
|
||||
// persistCharacterAssetsTransition writes only the durable records covered by
|
||||
// CharacterAssetUnitOfWork. The old side intentionally comes from the last
|
||||
// persisted state rather than the live runtime: an earlier async mutation may
|
||||
// still be dirty, and this synchronous asset commit must include the current
|
||||
// character-owned values without flushing unrelated records.
|
||||
func persistCharacterAssetsTransition(
|
||||
ctx context.Context,
|
||||
database *sql.DB,
|
||||
persisted memory.State,
|
||||
after characterAssetsSnapshot,
|
||||
) error {
|
||||
oldRecords, err := characterAssetsStateRecords(persisted, after.character.CharacterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRecords, err := characterAssetsSnapshotRecords(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return persistRecordTransition(ctx, database, oldRecords, newRecords)
|
||||
}
|
||||
|
||||
func characterAssetsStateRecords(state memory.State, characterID string) (map[string]persistedRecord, error) {
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" {
|
||||
return nil, fmt.Errorf("sqlite character-asset record key is empty")
|
||||
}
|
||||
out := make(map[string]persistedRecord, 3)
|
||||
for _, record := range state.Characters {
|
||||
if record.CharacterID != characterID {
|
||||
continue
|
||||
}
|
||||
persisted, err := makePersistedRecord(kindCharacter, record.CharacterID, record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[persisted.kind+"\x00"+persisted.key] = persisted
|
||||
break
|
||||
}
|
||||
for _, record := range state.Inventories {
|
||||
if record.CharacterID != characterID {
|
||||
continue
|
||||
}
|
||||
persisted, err := makePersistedRecord(kindInventory, record.CharacterID, record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[persisted.kind+"\x00"+persisted.key] = persisted
|
||||
break
|
||||
}
|
||||
for _, record := range state.Equipment {
|
||||
if record.CharacterID != characterID {
|
||||
continue
|
||||
}
|
||||
persisted, err := makePersistedRecord(kindEquipment, record.CharacterID, record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[persisted.kind+"\x00"+persisted.key] = persisted
|
||||
break
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func characterAssetsSnapshotRecords(snapshot characterAssetsSnapshot) (map[string]persistedRecord, error) {
|
||||
out := make(map[string]persistedRecord, 3)
|
||||
appendRecord := func(kind string, found bool, key string, value any) error {
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
persisted, err := makePersistedRecord(kind, key, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[persisted.kind+"\x00"+persisted.key] = persisted
|
||||
return nil
|
||||
}
|
||||
if err := appendRecord(kindCharacter, snapshot.characterFound, snapshot.character.CharacterID, snapshot.character); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := appendRecord(kindInventory, snapshot.inventoryFound, snapshot.inventory.CharacterID, snapshot.inventory); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := appendRecord(kindEquipment, snapshot.equipmentFound, snapshot.equipment.CharacterID, snapshot.equipment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func persistRecordTransition(
|
||||
ctx context.Context,
|
||||
database *sql.DB,
|
||||
oldRecords map[string]persistedRecord,
|
||||
newRecords map[string]persistedRecord,
|
||||
) error {
|
||||
tx, err := database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin sqlite repository transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for id, oldRecord := range oldRecords {
|
||||
if _, ok := newRecords[id]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM dnf_repository_records WHERE record_kind = ? AND record_key = ?`, oldRecord.kind, oldRecord.key); err != nil {
|
||||
return fmt.Errorf("delete sqlite repository %s %q: %w", oldRecord.kind, oldRecord.key, err)
|
||||
}
|
||||
}
|
||||
updatedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
for id, record := range newRecords {
|
||||
if oldRecord, ok := oldRecords[id]; ok && oldRecord.checksum == record.checksum {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO dnf_repository_records (record_kind, record_key, payload, checksum, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(record_kind, record_key) DO UPDATE SET
|
||||
payload = excluded.payload,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = excluded.updated_at`, record.kind, record.key, record.payload, record.checksum, updatedAt); err != nil {
|
||||
return fmt.Errorf("upsert sqlite repository %s %q: %w", record.kind, record.key, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO dnf_repository_meta (meta_key, meta_value) VALUES ('state_version', ?)
|
||||
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value`, strconv.Itoa(memory.StateVersion)); err != nil {
|
||||
return fmt.Errorf("write sqlite repository state version: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit sqlite repository transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
type persistentStore[T any] struct {
|
||||
component *Component
|
||||
selectStore func(repository.Group) db.Store[T]
|
||||
}
|
||||
|
||||
func (s *persistentStore[T]) Load(ctx context.Context, key string) (record T, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var loadErr error
|
||||
record, found, loadErr = s.selectStore(group).Load(ctx, key)
|
||||
return loadErr
|
||||
})
|
||||
return record, found, err
|
||||
}
|
||||
|
||||
func (s *persistentStore[T]) Save(ctx context.Context, record T) error {
|
||||
return s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
return s.selectStore(group).Save(ctx, record)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *persistentStore[T]) Check(ctx context.Context) error { return s.component.check(ctx) }
|
||||
|
||||
type accountStore struct {
|
||||
*persistentStore[repository.AccountRecord]
|
||||
}
|
||||
|
||||
func (s *accountStore) ListAccounts(ctx context.Context, query string, limit int) (records []repository.AccountRecord, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var listErr error
|
||||
records, listErr = repository.ListAccounts(ctx, group.Account, query, limit)
|
||||
return listErr
|
||||
})
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (s *accountStore) FindAccountIDByLoginUsername(ctx context.Context, username string) (string, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
username = strings.ToLower(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if s == nil || s.component == nil || s.component.db == nil {
|
||||
return "", false, ErrDatabaseRequired
|
||||
}
|
||||
var accountID string
|
||||
err := s.component.db.QueryRowContext(ctx,
|
||||
`SELECT account_id FROM dnf_login_accounts WHERE username = ? AND state = 'active' LIMIT 1`, username,
|
||||
).Scan(&accountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("find SQLite login username: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(accountID), true, nil
|
||||
}
|
||||
|
||||
func (s *accountStore) SaveMetadataEntry(ctx context.Context, accountID, key, value string, updatedAt time.Time) error {
|
||||
return s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
writer, ok := group.Account.(repository.AccountMetadataEntryWriter)
|
||||
if !ok {
|
||||
return repository.ErrAccountMetadataUnavailable
|
||||
}
|
||||
return writer.SaveMetadataEntry(ctx, accountID, key, value, updatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *accountStore) FindAccountIDByRepresentName(ctx context.Context, name string) (accountID string, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
finder, ok := group.Account.(repository.RepresentAccountNameFinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("account represent-name lookup is unavailable")
|
||||
}
|
||||
var findErr error
|
||||
accountID, found, findErr = finder.FindAccountIDByRepresentName(ctx, name)
|
||||
return findErr
|
||||
})
|
||||
return accountID, found, err
|
||||
}
|
||||
|
||||
type characterStore struct {
|
||||
*persistentStore[repository.CharacterRecord]
|
||||
}
|
||||
|
||||
type questStore struct {
|
||||
*persistentStore[repository.QuestRecord]
|
||||
}
|
||||
|
||||
type unitedFriendStore struct{ component *Component }
|
||||
|
||||
func (s *unitedFriendStore) Check(ctx context.Context) error { return s.component.check(ctx) }
|
||||
func (s *unitedFriendStore) Load(ctx context.Context, key string) (record repository.UnitedFriendRecord, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var loadErr error
|
||||
record, found, loadErr = group.UnitedFriend.Load(ctx, key)
|
||||
return loadErr
|
||||
})
|
||||
return record, found, err
|
||||
}
|
||||
func (s *unitedFriendStore) Save(ctx context.Context, record repository.UnitedFriendRecord) error {
|
||||
return s.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.UnitedFriend.Save(ctx, record)
|
||||
})
|
||||
}
|
||||
func (s *unitedFriendStore) ListObservers(ctx context.Context, friendName string) (owners []string, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var listErr error
|
||||
owners, listErr = group.UnitedFriend.ListObservers(ctx, friendName)
|
||||
return listErr
|
||||
})
|
||||
return owners, err
|
||||
}
|
||||
|
||||
// SaveQuestStateEntry mutates only one in-memory objective under SQLite's
|
||||
// serialized mutation gate. The resulting snapshot is then flushed by the
|
||||
// existing asynchronous WAL/checkpoint owner, preserving instant gameplay
|
||||
// acknowledgement without allowing a stale full record to overwrite another
|
||||
// objective update.
|
||||
func (s *questStore) SaveQuestStateEntry(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
field repository.QuestField,
|
||||
questID int64,
|
||||
state repository.QuestState,
|
||||
updatedAt time.Time,
|
||||
) error {
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
if characterID == "" {
|
||||
return db.ErrRecordKeyRequired
|
||||
}
|
||||
if field != repository.QuestFieldStates && field != repository.QuestFieldProgress {
|
||||
return fmt.Errorf("unsupported quest state field %q", field)
|
||||
}
|
||||
cloned := repository.CloneQuest(repository.QuestRecord{
|
||||
States: map[int64]repository.QuestState{questID: state},
|
||||
}).States[questID]
|
||||
return s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
record, found, err := group.Quest.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
record = repository.QuestRecord{CharacterID: characterID}
|
||||
} else {
|
||||
record = repository.CloneQuest(record)
|
||||
}
|
||||
switch field {
|
||||
case repository.QuestFieldStates:
|
||||
if record.States == nil {
|
||||
record.States = make(map[int64]repository.QuestState)
|
||||
}
|
||||
record.States[questID] = cloned
|
||||
case repository.QuestFieldProgress:
|
||||
if record.Progress == nil {
|
||||
record.Progress = make(map[int64]repository.QuestState)
|
||||
}
|
||||
record.Progress[questID] = cloned
|
||||
}
|
||||
record.CharacterID = characterID
|
||||
record.UpdatedAt = updatedAt
|
||||
return group.Quest.Save(ctx, record)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *characterStore) ListByAccount(ctx context.Context, accountID string, limit int) (records []repository.CharacterRecord, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var listErr error
|
||||
records, listErr = group.Character.ListByAccount(ctx, accountID, limit)
|
||||
return listErr
|
||||
})
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (s *characterStore) FindIDByName(ctx context.Context, name string) (characterID string, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var findErr error
|
||||
characterID, found, findErr = group.Character.FindIDByName(ctx, name)
|
||||
return findErr
|
||||
})
|
||||
return characterID, found, err
|
||||
}
|
||||
|
||||
func (s *characterStore) NextNumericID(ctx context.Context) (next int, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var nextErr error
|
||||
next, nextErr = group.Character.NextNumericID(ctx)
|
||||
return nextErr
|
||||
})
|
||||
return next, err
|
||||
}
|
||||
|
||||
func (s *characterStore) CreateCharacter(ctx context.Context, record repository.CharacterRecord) error {
|
||||
return s.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
creator, ok := group.Character.(repository.CharacterCreator)
|
||||
if !ok {
|
||||
return repository.ErrCharacterCreateMissing
|
||||
}
|
||||
return creator.CreateCharacter(ctx, record)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *characterStore) SwapCharacterSlots(ctx context.Context, accountID string, slotA, slotB int) error {
|
||||
return s.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
swapper, ok := group.Character.(repository.CharacterSlotSwapper)
|
||||
if !ok {
|
||||
return repository.ErrCharacterSlotSwapUnavailable
|
||||
}
|
||||
return swapper.SwapCharacterSlots(ctx, accountID, slotA, slotB)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *characterStore) DeleteCharacterAndCompact(ctx context.Context, accountID string, slot int, name string) error {
|
||||
return s.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
deleter, ok := group.Character.(repository.CharacterRosterDeleter)
|
||||
if !ok {
|
||||
return repository.ErrCharacterDeleteUnavailable
|
||||
}
|
||||
return deleter.DeleteCharacterAndCompact(ctx, accountID, slot, name)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *characterStore) AdvanceStoryDigest(ctx context.Context, characterID string, level, migrationVersion uint32) error {
|
||||
return s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
advancer, ok := group.Character.(repository.CharacterStoryDigestAdvancer)
|
||||
if !ok {
|
||||
return repository.ErrCharacterStoryDigestAdvanceUnavailable
|
||||
}
|
||||
return advancer.AdvanceStoryDigest(ctx, characterID, level, migrationVersion)
|
||||
})
|
||||
}
|
||||
|
||||
type dungeonPermissionStore struct{ component *Component }
|
||||
|
||||
func (s *dungeonPermissionStore) Check(ctx context.Context) error { return s.component.check(ctx) }
|
||||
func (s *dungeonPermissionStore) Load(ctx context.Context, key string) (record repository.DungeonPermissionRecord, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var loadErr error
|
||||
record, found, loadErr = group.DungeonPermission.Load(ctx, key)
|
||||
return loadErr
|
||||
})
|
||||
return record, found, err
|
||||
}
|
||||
func (s *dungeonPermissionStore) UpsertMax(ctx context.Context, characterID string, dungeonID uint32, clearState byte) (entry repository.DungeonPermissionEntry, changed bool, err error) {
|
||||
err = s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
var upsertErr error
|
||||
entry, changed, upsertErr = group.DungeonPermission.UpsertMax(ctx, characterID, dungeonID, clearState)
|
||||
return upsertErr
|
||||
})
|
||||
return entry, changed, err
|
||||
}
|
||||
|
||||
type hellPartyPermissionStore struct{ component *Component }
|
||||
|
||||
func (s *hellPartyPermissionStore) Check(ctx context.Context) error { return s.component.check(ctx) }
|
||||
func (s *hellPartyPermissionStore) Load(ctx context.Context, key string) (record repository.HellPartyPermissionRecord, found bool, err error) {
|
||||
err = s.component.read(ctx, func(group repository.Group) error {
|
||||
var loadErr error
|
||||
record, found, loadErr = group.HellPartyPermission.Load(ctx, key)
|
||||
return loadErr
|
||||
})
|
||||
return record, found, err
|
||||
}
|
||||
func (s *hellPartyPermissionStore) Upsert(ctx context.Context, characterID string, areaID, questID int64) (entry repository.HellPartyPermissionEntry, changed bool, err error) {
|
||||
err = s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
var upsertErr error
|
||||
entry, changed, upsertErr = group.HellPartyPermission.Upsert(ctx, characterID, areaID, questID)
|
||||
return upsertErr
|
||||
})
|
||||
return entry, changed, err
|
||||
}
|
||||
func (s *hellPartyPermissionStore) Delete(ctx context.Context, characterID string, areaID int64) error {
|
||||
return s.component.mutateAsync(ctx, func(group repository.Group) error {
|
||||
deleter, ok := group.HellPartyPermission.(repository.HellPartyPermissionRestorer)
|
||||
if !ok {
|
||||
return fmt.Errorf("hell-party permission delete is unavailable")
|
||||
}
|
||||
return deleter.Delete(ctx, characterID, areaID)
|
||||
})
|
||||
}
|
||||
|
||||
func newPersistentStore[T any](component *Component, selector func(repository.Group) db.Store[T]) *persistentStore[T] {
|
||||
return &persistentStore[T]{component: component, selectStore: selector}
|
||||
}
|
||||
|
||||
func buildGroup(component *Component) repository.Group {
|
||||
account := &accountStore{newPersistentStore(component, func(g repository.Group) db.Store[repository.AccountRecord] { return g.Account })}
|
||||
character := &characterStore{newPersistentStore(component, func(g repository.Group) db.Store[repository.CharacterRecord] { return g.Character })}
|
||||
quest := &questStore{newPersistentStore(component, func(g repository.Group) db.Store[repository.QuestRecord] { return g.Quest })}
|
||||
transactions := &unitOfWork{component: component}
|
||||
return repository.Group{
|
||||
Account: account,
|
||||
AccountInventory: newPersistentStore(component, func(g repository.Group) db.Store[repository.AccountInventoryRecord] { return g.AccountInventory }),
|
||||
Character: character,
|
||||
Inventory: newPersistentStore(component, func(g repository.Group) db.Store[repository.InventoryRecord] { return g.Inventory }),
|
||||
Equipment: newPersistentStore(component, func(g repository.Group) db.Store[repository.EquipmentRecord] { return g.Equipment }),
|
||||
Pet: newPersistentStore(component, func(g repository.Group) db.Store[repository.PetRecord] { return g.Pet }),
|
||||
Quest: quest,
|
||||
Skill: newPersistentStore(component, func(g repository.Group) db.Store[repository.SkillRecord] { return g.Skill }),
|
||||
DungeonPermission: &dungeonPermissionStore{component: component},
|
||||
HellPartyPermission: &hellPartyPermissionStore{component: component},
|
||||
PacketTemplate: newPersistentStore(component, func(g repository.Group) db.Store[repository.PacketTemplateRecord] { return g.PacketTemplate }),
|
||||
Settings: newPersistentStore(component, func(g repository.Group) db.Store[repository.SettingsRecord] { return g.Settings }),
|
||||
Mailbox: newPersistentStore(component, func(g repository.Group) db.Store[repository.MailboxRecord] { return g.Mailbox }),
|
||||
EpicBook: newPersistentStore(component, func(g repository.Group) db.Store[repository.EpicBookRecord] { return g.EpicBook }),
|
||||
UnitedFriend: &unitedFriendStore{component: component},
|
||||
CharacterCreate: transactions,
|
||||
CharacterItems: transactions,
|
||||
CharacterTrade: transactions,
|
||||
AccountCharacterTrade: transactions,
|
||||
AccountPairTrade: transactions,
|
||||
CharacterPets: transactions,
|
||||
AccountItems: transactions,
|
||||
CharacterAssets: transactions,
|
||||
MailboxAssets: transactions,
|
||||
AccountAssets: transactions,
|
||||
BoosterAssets: transactions,
|
||||
RentalAssets: transactions,
|
||||
CeraShopAssets: transactions,
|
||||
CharacterSkills: transactions,
|
||||
CharacterProgression: transactions,
|
||||
CharacterSettlement: transactions,
|
||||
EpicBookCraft: transactions,
|
||||
RaidEntryCost: transactions,
|
||||
RaidRewards: transactions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
type unitOfWork struct{ component *Component }
|
||||
|
||||
func (u *unitOfWork) WithinCharacterCreation(ctx context.Context, characterID string, _ repository.Group, apply func(repository.Group) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterCreate.WithinCharacterCreation(ctx, characterID, group, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinRaidEntryCost(ctx context.Context, characterIDs []string, apply func(repository.InventoryRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.RaidEntryCost.WithinRaidEntryCost(ctx, characterIDs, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinRaidRewards(ctx context.Context, characterIDs []string, receipt string, apply func(repository.InventoryRepository, repository.SettingsRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.RaidRewards.WithinRaidRewards(ctx, characterIDs, receipt, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterItems(ctx context.Context, characterID string, apply func(repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterItems.WithinCharacterItems(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterItemsScoped(ctx context.Context, characterID string, apply func(repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateCharacterItemsScopedSync(ctx, characterID, apply)
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterTrade(ctx context.Context, firstID, secondID string, apply func(repository.CharacterRepository, repository.InventoryRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterTrade.WithinCharacterTrade(ctx, firstID, secondID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinAccountCharacterTrade(ctx context.Context, accountID, firstID, secondID string, apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EpicBookRepository, repository.PetRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.AccountCharacterTrade.WithinAccountCharacterTrade(ctx, accountID, firstID, secondID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinAccountPairCharacterTrade(ctx context.Context, firstAccountID, secondAccountID, firstID, secondID string, apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
if group.AccountPairTrade == nil {
|
||||
return repository.ErrAccountCharacterTradeTransactionUnavailable
|
||||
}
|
||||
return group.AccountPairTrade.WithinAccountPairCharacterTrade(ctx, firstAccountID, secondAccountID, firstID, secondID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterPets(ctx context.Context, characterID string, apply func(repository.InventoryRepository, repository.EquipmentRepository, repository.PetRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterPets.WithinCharacterPets(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterPetsScoped(ctx context.Context, characterID string, apply func(repository.InventoryRepository, repository.EquipmentRepository, repository.PetRepository) error) error {
|
||||
return u.component.mutateCharacterPetsScopedSync(ctx, characterID, apply)
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinAccountCharacterItems(ctx context.Context, accountID, characterID string, apply func(repository.AccountInventoryRepository, repository.InventoryRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.AccountItems.WithinAccountCharacterItems(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterAssets(ctx context.Context, characterID string, apply func(repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterAssets.WithinCharacterAssets(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterAssetsScoped(ctx context.Context, characterID string, apply func(repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateCharacterAssetsSync(ctx, characterID, apply)
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinMailboxAssets(ctx context.Context, characterID, mailboxID string, apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.MailboxRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.MailboxAssets.WithinMailboxAssets(ctx, characterID, mailboxID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinMailboxClaimAssets(ctx context.Context, characterID, mailboxID string, apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.MailboxRepository, repository.EpicBookRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
claimAssets, ok := group.MailboxAssets.(repository.MailboxClaimAssetUnitOfWork)
|
||||
if !ok {
|
||||
return repository.ErrMailboxAssetTransactionUnavailable
|
||||
}
|
||||
return claimAssets.WithinMailboxClaimAssets(ctx, characterID, mailboxID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinAccountCharacterAssets(ctx context.Context, accountID, characterID string, apply func(repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.AccountAssets.WithinAccountCharacterAssets(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinShopPurchaseAssets(ctx context.Context, accountID, characterID string, apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
shop, ok := group.AccountAssets.(repository.ShopPurchaseAssetUnitOfWork)
|
||||
if !ok {
|
||||
return repository.ErrShopPurchaseAssetTransactionUnavailable
|
||||
}
|
||||
return shop.WithinShopPurchaseAssets(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinBoosterAssets(ctx context.Context, accountID, characterID string, apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository, repository.EpicBookRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.BoosterAssets.WithinBoosterAssets(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinRentalAssets(ctx context.Context, accountID, characterID string, apply func(repository.AccountRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.RentalAssets.WithinRentalAssets(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinRentalAssetsScoped(ctx context.Context, accountID, characterID string, apply func(repository.AccountRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateRentalAssetsScopedSync(ctx, accountID, characterID, apply)
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCeraShopAssets(ctx context.Context, accountID, characterID, settingsScope string, apply func(repository.AccountRepository, repository.AccountInventoryRepository, repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository, repository.SettingsRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CeraShopAssets.WithinCeraShopAssets(ctx, accountID, characterID, settingsScope, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterSkill(ctx context.Context, characterID string, apply func(repository.SkillRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterSkills.WithinCharacterSkill(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterProgression(ctx context.Context, characterID string, apply func(repository.CharacterRepository, repository.SkillRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterProgression.WithinCharacterProgression(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinCharacterSettlement(ctx context.Context, characterID string, apply func(repository.Group) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, apply)
|
||||
})
|
||||
}
|
||||
|
||||
func (u *unitOfWork) WithinEpicBookCraft(ctx context.Context, accountID, characterID string, apply func(repository.EpicBookRepository, repository.AccountInventoryRepository, repository.InventoryRepository, repository.EquipmentRepository) error) error {
|
||||
return u.component.mutateSync(ctx, func(group repository.Group) error {
|
||||
return group.EpicBookCraft.WithinEpicBookCraft(ctx, accountID, characterID, apply)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user