M8: progression, items, bankarta och sparfil
All checks were successful
release / build-release (push) Successful in 1m46s

- internal/progress: katalog (skills.json 12 noder, items.json 25 items)
  med validering; XP/nivåer (150·n(n+1)/2) och skillpoäng; skillträd med
  krav och kostnad; items per plats med buffar, genvägar (JR Pass, kartor,
  powerbank) och färgton; banresultat och upplåsning (huvudbanor i ordning,
  sidospår efter föräldern); 33 %-regeln som test
- internal/save: versionerad JSON med kunskap, plånbok, debuffar,
  progression och pågående bana + seed; atomisk skrivning; migrering;
  ~/.config/metro-puzzle-jp/save.json resp. %AppData%
- sim: RunOptions får Extra/FogRadius/Landmarks/FreeGates/RevealAll/
  InfiniteBattery; luck påverkar trängselzonen; Fog.RevealAll och extra
  landmärken
- game: bankarta i spelbrädesstil ersätter menyn (⟳ återupptar med sparad
  seed), skillträd (T), garderob (I), butik i kiosken (B), XP/nivå/items/
  upplåsningar på resultatet, spelaren tonas av skins; autospar vid banstart,
  efter banan och vid köp; flaggor -save/-nosave
- level: kind/order/parent/rewards/shop; ny sidobana 002b Kabukicho som
  belönar skinnet Kimono; 001 belönar Salaryman-skinnet; butikssortiment
  på 002/003
- tools/shots: demo-sparfil (build/, aldrig den riktiga), bilder av karta,
  skills, garderob och butik; alla körningar med -nosave
- docs/README/wiki uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 11:29:30 +02:00
parent d466333f01
commit 915c684dc2
46 changed files with 3058 additions and 316 deletions

122
internal/save/save.go Normal file
View File

@@ -0,0 +1,122 @@
// 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"
"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"`
}
// 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)
}