DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,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)
})
}