feat: migrate state and complete live systems

- replace the Haskell bridge with transactional SQLite state and validation

- centralize versions and harden resource, capture, and release tooling

- complete active gacha schedules, point exchange, and mail grants

- add equipment refinement, presets, and ordinary formation workflows

- improve client plugins, request timing, and normalized persistence
This commit is contained in:
2026-09-24 22:43:03 +08:00
parent 113e941bef
commit f94cfa1cf3
162 changed files with 13714 additions and 12632 deletions
+27 -67
View File
@@ -7,13 +7,12 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"bd2server/internal/stateio"
"bd2server/internal/wire"
)
@@ -49,7 +48,7 @@ type QuestProgress struct {
type Store struct {
mu sync.RWMutex
path string
storage stateio.Store
position SavedPosition
tutorials map[int]struct{}
quests map[string]QuestProgress
@@ -61,7 +60,7 @@ func NewStore() *Store {
}
type snapshot struct {
Version int `json:"version,omitempty"`
Version int `json:"version"`
Position SavedPosition `json:"position"`
Tutorials []int `json:"tutorials"`
Quests map[string]QuestProgress `json:"quests"`
@@ -84,29 +83,30 @@ func parseQuestKey(key string) (int, int, bool) {
return packID, questID, packErr == nil && questErr == nil && packID > 0 && questID > 0
}
// OpenStore recovers player progress from one local JSON save. Nothing is
// created on disk until a validated state change is committed.
func OpenStore(path string) (*Store, error) {
// OpenStore recovers player progress from its domain snapshot.
func OpenStore(storage stateio.Store) (*Store, error) {
s := NewStore()
if path == "" {
return nil, errors.New("progress: empty save path")
if storage == nil {
return nil, errors.New("progress: nil storage")
}
s.path = filepath.Clean(path)
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
s.storage = storage
data, err := storage.Load("progress")
if err != nil {
return nil, fmt.Errorf("progress: load state: %w", err)
}
if data == nil {
return s, nil
}
if err != nil {
return nil, fmt.Errorf("progress: read save: %w", err)
if err := stateio.RequireExactJSONObject(data, "version", "position", "tutorials", "quests", "cleared_quests"); err != nil {
return nil, fmt.Errorf("progress: incompatible save layout: %w", err)
}
var state snapshot
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("progress: malformed save: %w", err)
}
if state.Version != 0 && state.Version != snapshotVersion {
if state.Version != snapshotVersion {
return nil, fmt.Errorf("progress: unsupported save version %d", state.Version)
}
legacy := state.Version == 0
s.position = state.Position
for _, id := range state.Tutorials {
if id <= 0 {
@@ -118,29 +118,13 @@ func OpenStore(path string) (*Store, error) {
if quest.QuestID <= 0 || quest.PackID <= 0 {
return nil, errors.New("progress: invalid saved quest")
}
if legacy {
id, err := strconv.Atoi(key)
if err != nil || id != quest.QuestID {
return nil, errors.New("progress: invalid saved quest")
}
} else {
packID, questID, ok := parseQuestKey(key)
if !ok || packID != quest.PackID || questID != quest.QuestID {
return nil, errors.New("progress: invalid saved quest key")
}
packID, questID, ok := parseQuestKey(key)
if !ok || packID != quest.PackID || questID != quest.QuestID {
return nil, errors.New("progress: invalid saved quest key")
}
s.quests[questKey(quest.PackID, quest.QuestID)] = quest
}
for key, raw := range state.Cleared {
if legacy {
questID, err := strconv.Atoi(key)
var packID int
if err != nil || json.Unmarshal(raw, &packID) != nil || questID <= 0 || packID <= 0 {
return nil, errors.New("progress: invalid cleared quest")
}
s.cleared[questKey(packID, questID)] = struct{}{}
continue
}
packID, questID, ok := parseQuestKey(key)
var cleared bool
if !ok || json.Unmarshal(raw, &cleared) != nil || !cleared {
@@ -148,32 +132,26 @@ func OpenStore(path string) (*Store, error) {
}
s.cleared[questKey(packID, questID)] = struct{}{}
}
if legacy {
// Install the unambiguous pack+quest schema immediately. Adjacent story
// packs reuse quest numbers, so delaying migration until a later-pack
// update would risk overwriting valid earlier-pack progress.
if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil {
return nil, fmt.Errorf("progress: migrate legacy save: %w", err)
}
}
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := os.Stat(s.path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
data, err := s.storage.Load("progress")
if err != nil {
return err
}
if data != nil {
return nil
}
return s.commit(s.position, s.tutorials, s.quests, s.cleared)
}
// commit writes a complete snapshot before exposing the new state. Caller
// holds mu; failure leaves the in-memory player state unchanged.
func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error {
if s.path != "" {
if s.storage != nil {
state := snapshot{Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))}
for id := range tutorials {
state.Tutorials = append(state.Tutorials, id)
@@ -186,26 +164,8 @@ func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quest
if err != nil {
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("progress: create save dir: %w", err)
}
file, err := os.CreateTemp(dir, ".progress-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return fmt.Errorf("progress: write save: %w", err)
}
if err := os.Rename(file.Name(), s.path); err != nil {
return fmt.Errorf("progress: install save: %w", err)
if err := s.storage.Save("progress", data); err != nil {
return fmt.Errorf("progress: save state: %w", err)
}
}
s.position, s.tutorials, s.quests, s.cleared = position, tutorials, quests, cleared