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