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:
@@ -2,21 +2,20 @@ package progress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/stateio"
|
||||
"bd2server/internal/wire"
|
||||
)
|
||||
|
||||
func TestStorePersistsProgressAcrossRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player", "progress.json")
|
||||
store, err := OpenStore(path)
|
||||
storage := stateio.NewMemory()
|
||||
store, err := OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected initial save: %v", err)
|
||||
if data, _ := storage.Load("progress"); data != nil {
|
||||
t.Fatalf("unexpected initial save: %s", data)
|
||||
}
|
||||
position := wire.AppendVarint(nil, 2, 21)
|
||||
position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3}}`)
|
||||
@@ -32,7 +31,7 @@ func TestStorePersistsProgressAcrossRestart(t *testing.T) {
|
||||
if _, err := store.UpdateQuest(quest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := OpenStore(path)
|
||||
reopened, err := OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,18 +44,18 @@ func TestStorePersistsProgressAcrossRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMigratesLegacyQuestKeysAndKeepsPackOverlap(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "progress.json")
|
||||
legacy := `{"quests":{"1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"1":21}}`
|
||||
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
|
||||
func TestStoreKeepsPackOverlap(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
initial := `{"version":2,"position":{"PackID":0,"Position":{"MapId":0,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null},"RawJSON":""},"tutorials":[],"quests":{"21:1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"21:1":true}}`
|
||||
if err := storage.Save("progress", []byte(initial)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := OpenStore(path)
|
||||
store, err := OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !store.QuestCleared(1, 21) || store.QuestCleared(1, 22) {
|
||||
t.Fatal("legacy pack21 clear was not migrated with its pack identity")
|
||||
t.Fatal("pack21 clear was not loaded with its pack identity")
|
||||
}
|
||||
request := wire.AppendVarint(nil, 2, 1)
|
||||
request = wire.AppendVarint(request, 3, 22)
|
||||
@@ -78,7 +77,7 @@ func TestStoreMigratesLegacyQuestKeysAndKeepsPackOverlap(t *testing.T) {
|
||||
t.Fatalf("pack%d quest missing: %+v found=%v", packID, quest, found)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := storage.Load("progress")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,20 +87,28 @@ func TestStoreMigratesLegacyQuestKeysAndKeepsPackOverlap(t *testing.T) {
|
||||
}
|
||||
var version int
|
||||
if err := json.Unmarshal(saved["version"], &version); err != nil || version != snapshotVersion {
|
||||
t.Fatalf("migrated version=%d err=%v", version, err)
|
||||
t.Fatalf("saved version=%d err=%v", version, err)
|
||||
}
|
||||
var cleared map[string]bool
|
||||
if err := json.Unmarshal(saved["cleared_quests"], &cleared); err != nil || !cleared["21:1"] || !cleared["22:1"] {
|
||||
t.Fatalf("migrated clears=%v err=%v", cleared, err)
|
||||
t.Fatalf("saved clears=%v err=%v", cleared, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsBrokenSave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "broken.json")
|
||||
if err := os.WriteFile(path, []byte(`{"quests":{"12":{"QuestID":5,"PackID":21}}}`), 0o600); err != nil {
|
||||
storage := stateio.NewMemory()
|
||||
if err := storage.Save("progress", []byte(`{"version":2,"quests":{"12":{"QuestID":5,"PackID":21}}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenStore(path); err == nil {
|
||||
if _, err := OpenStore(storage); err == nil {
|
||||
t.Fatal("accepted inconsistent save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsLegacySave(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
_ = storage.Save("progress", []byte(`{"quests":{"1":{"QuestID":1,"PackID":21}}}`))
|
||||
if _, err := OpenStore(storage); err == nil {
|
||||
t.Fatal("accepted unversioned legacy state")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user