All checks were successful
release / build-release (push) Successful in 1m34s
internal/endless lottar mål ur en seed: en av storyns nätkopplade stationer (aldrig samma två gånger i rad) med ett resmål som minst ett tåg går rakt till men inte alla; banan kopieras, bedöms om mot nätet, sällskapet växer var tredje mål och tidsgränsen krymper. O på bankartan (eller -endless) startar en omgång; klarade mål ger poäng och XP, Enter ger nästa mål, förlust eller Esc avslutar och omgången hamnar i sparfilens poänglista (fem bästa). G två gånger startar New Game+: items, banresultat, pengar och debuffar nollställs, skills, XP och japanskan behålls, sällskapet är en person större per varv. Docs, wiki, tester och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
157 lines
4.5 KiB
Go
157 lines
4.5 KiB
Go
// Package save läser och skriver sparfilen: kunskap, plånbok, debuffar,
|
|
// progression och den pågående omgångens bana + seed. JSON med
|
|
// versionsfält så att gamla filer kan migreras.
|
|
package save
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
|
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
|
|
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
|
|
)
|
|
|
|
// Version är sparfilens formatversion.
|
|
const Version = 1
|
|
|
|
// AppDir är mappnamnet under användarens konfigmapp.
|
|
const AppDir = "metro-puzzle-jp"
|
|
|
|
// RunRef pekar ut en pågående omgång så att den kan återupptas med samma
|
|
// slump (minispel, händelser, läsning).
|
|
type RunRef struct {
|
|
LevelID string `json:"levelId"`
|
|
Seed [2]uint64 `json:"seed"`
|
|
Started time.Time `json:"started"`
|
|
}
|
|
|
|
// State är allt som sparas.
|
|
type State struct {
|
|
Version int `json:"version"`
|
|
SavedAt time.Time `json:"savedAt"`
|
|
Know *lang.Knowledge `json:"knowledge"`
|
|
Wallet *sim.Wallet `json:"wallet"`
|
|
Profile *sim.Profile `json:"profile"`
|
|
Progress *progress.Progress `json:"progress"`
|
|
Current *RunRef `json:"current,omitempty"`
|
|
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
|
|
Endless []EndlessRecord `json:"endless,omitempty"` // oändligt läge: bästa omgångarna, högst poäng först
|
|
}
|
|
|
|
// EndlessRecord är en avslutad omgång i oändligt läge.
|
|
type EndlessRecord struct {
|
|
Score int `json:"score"`
|
|
Goals int `json:"goals"` // antal klarade mål
|
|
Seed [2]uint64 `json:"seed"`
|
|
When time.Time `json:"when"`
|
|
}
|
|
|
|
// MaxEndlessRecords är hur många omgångar som sparas i listan.
|
|
const MaxEndlessRecords = 5
|
|
|
|
// AddEndless lägger in en omgång i listan (sorterad på poäng, högst först)
|
|
// och rapporterar om den är ny bästa.
|
|
func (s *State) AddEndless(r EndlessRecord) (best bool) {
|
|
best = len(s.Endless) == 0 || r.Score > s.Endless[0].Score
|
|
s.Endless = append(s.Endless, r)
|
|
sort.SliceStable(s.Endless, func(i, j int) bool { return s.Endless[i].Score > s.Endless[j].Score })
|
|
if len(s.Endless) > MaxEndlessRecords {
|
|
s.Endless = s.Endless[:MaxEndlessRecords]
|
|
}
|
|
return best
|
|
}
|
|
|
|
// BestEndless är bästa omgången (nil om ingen).
|
|
func (s *State) BestEndless() *EndlessRecord {
|
|
if len(s.Endless) == 0 {
|
|
return nil
|
|
}
|
|
return &s.Endless[0]
|
|
}
|
|
|
|
// New är ett nytt spel.
|
|
func New() *State {
|
|
return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()}
|
|
}
|
|
|
|
// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på
|
|
// Linux, %AppData%\metro-puzzle-jp\save.json på Windows.
|
|
func DefaultPath() (string, error) {
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, AppDir, "save.json"), nil
|
|
}
|
|
|
|
// Load läser sparfilen. Saknas den returneras ett nytt spel (och exists=false).
|
|
func Load(path string) (st *State, exists bool, err error) {
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return New(), false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
var s State
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return nil, true, fmt.Errorf("sparfilen %s är trasig: %w", path, err)
|
|
}
|
|
if err := migrate(&s); err != nil {
|
|
return nil, true, err
|
|
}
|
|
return &s, true, nil
|
|
}
|
|
|
|
// migrate lyfter äldre versioner till den aktuella och fyller i luckor.
|
|
func migrate(s *State) error {
|
|
if s.Version > Version {
|
|
return fmt.Errorf("sparfilen är från en nyare version (%d > %d)", s.Version, Version)
|
|
}
|
|
// Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg.
|
|
s.Version = Version
|
|
if s.Know == nil {
|
|
s.Know = lang.NewKnowledge()
|
|
}
|
|
if s.Know.Words == nil {
|
|
s.Know.Words = map[string]bool{}
|
|
}
|
|
if s.Know.Progress == nil {
|
|
s.Know.Progress = map[string]int{}
|
|
}
|
|
if s.Wallet == nil {
|
|
s.Wallet = sim.NewWallet()
|
|
}
|
|
if s.Profile == nil {
|
|
s.Profile = &sim.Profile{}
|
|
}
|
|
if s.Progress == nil {
|
|
s.Progress = progress.New()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Store skriver sparfilen atomiskt (tempfil + byte av namn).
|
|
func Store(path string, s *State) error {
|
|
s.Version = Version
|
|
s.SavedAt = time.Now()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|