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

View File

@@ -0,0 +1,168 @@
// Package progress är spelarens progression: XP och nivåer, skill tree,
// items med buffar, klarade banor och upplåsning av bankartan. Ren Go;
// katalogerna (skills.json, items.json) läses från resources/data.
package progress
import (
"encoding/json"
"fmt"
"sort"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// Effect är vad en skill eller ett item gör. Allt är valfritt.
type Effect struct {
Stats map[string]float64 `json:"stats"` // speed/thirst/focus/toilet/luck → multiplikator
Kana bool `json:"kana"` // kan läsa kana
FogRadius int `json:"fogRadius"` // extra siktsteg i gångarna
Landmarks bool `json:"landmarks"` // toaletter/kiosker/automater syns alltid på minikartan
FreeGates bool `json:"freeGates"` // spärrarna kostar inget (genväg)
RevealLevel string `json:"revealLevel"` // hela minikartan känd på banan
Battery bool `json:"battery"` // telefonen laddar inte ur
}
// Skill är en nod i språk-/skillträdet.
type Skill struct {
ID string `json:"id"`
Branch string `json:"branch"`
Name level.Text `json:"name"`
Desc level.Text `json:"desc"`
Cost int `json:"cost"`
Requires []string `json:"requires"`
Effect Effect `json:"effect"`
}
// Item är kläder, masker, hattar, prylar och skins.
type Item struct {
ID string `json:"id"`
Kind string `json:"kind"`
Slot string `json:"slot"`
Name level.Text `json:"name"`
Price int `json:"price"` // 0 = bara belöning
Effect Effect `json:"effect"`
Tint []float64 `json:"tint"` // färgton på spelaren (r, g, b) när det bärs
}
// Slots är kroppsplatserna; ett item per plats bärs.
var Slots = []string{"head", "face", "body", "feet", "bag"}
var statIndex = map[string]int{"speed": sim.StatSpeed, "thirst": sim.StatThirst, "focus": sim.StatFocus, "toilet": sim.StatToilet, "luck": sim.StatLuck}
// Catalog är alla skills och items.
type Catalog struct {
Skills []Skill
Items []Item
skills map[string]*Skill
items map[string]*Item
}
// LoadCatalog läser och validerar skills.json och items.json.
func LoadCatalog(skillsJSON, itemsJSON []byte) (*Catalog, error) {
var sk struct {
Skills []Skill `json:"skills"`
}
if err := json.Unmarshal(skillsJSON, &sk); err != nil {
return nil, fmt.Errorf("skills.json: %w", err)
}
var it struct {
Items []Item `json:"items"`
}
if err := json.Unmarshal(itemsJSON, &it); err != nil {
return nil, fmt.Errorf("items.json: %w", err)
}
c := &Catalog{Skills: sk.Skills, Items: it.Items, skills: map[string]*Skill{}, items: map[string]*Item{}}
for i := range c.Skills {
s := &c.Skills[i]
if s.ID == "" || s.Cost < 1 || s.Name["sv"] == "" || s.Name["en"] == "" {
return nil, fmt.Errorf("skill %q: saknar id/cost/namn", s.ID)
}
if _, dup := c.skills[s.ID]; dup {
return nil, fmt.Errorf("skill %q: dubblett", s.ID)
}
if err := s.Effect.validate(); err != nil {
return nil, fmt.Errorf("skill %q: %w", s.ID, err)
}
c.skills[s.ID] = s
}
for _, s := range c.Skills {
for _, r := range s.Requires {
if _, ok := c.skills[r]; !ok {
return nil, fmt.Errorf("skill %q kräver okänd skill %q", s.ID, r)
}
}
}
for i := range c.Items {
it := &c.Items[i]
if it.ID == "" || it.Name["sv"] == "" || it.Name["en"] == "" {
return nil, fmt.Errorf("item %q: saknar id/namn", it.ID)
}
if _, dup := c.items[it.ID]; dup {
return nil, fmt.Errorf("item %q: dubblett", it.ID)
}
if !validSlot(it.Slot) {
return nil, fmt.Errorf("item %q: okänd slot %q", it.ID, it.Slot)
}
if it.Price < 0 || (len(it.Tint) != 0 && len(it.Tint) != 3) {
return nil, fmt.Errorf("item %q: pris/tint", it.ID)
}
if err := it.Effect.validate(); err != nil {
return nil, fmt.Errorf("item %q: %w", it.ID, err)
}
c.items[it.ID] = it
}
return c, nil
}
func (e Effect) validate() error {
for k, v := range e.Stats {
if _, ok := statIndex[k]; !ok {
return fmt.Errorf("okänd stat %q", k)
}
if v <= 0 || v > 3 {
return fmt.Errorf("stat %s=%v utanför 0..3", k, v)
}
}
return nil
}
func validSlot(s string) bool {
for _, x := range Slots {
if x == s {
return true
}
}
return false
}
// Skill slår upp en skill (nil om okänd).
func (c *Catalog) Skill(id string) *Skill { return c.skills[id] }
// Item slår upp ett item (nil om okänt).
func (c *Catalog) Item(id string) *Item { return c.items[id] }
// Purchasable är alla items med pris, billigast först.
func (c *Catalog) Purchasable() []Item {
var out []Item
for _, it := range c.Items {
if it.Price > 0 {
out = append(out, it)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Price < out[j].Price })
return out
}
// ThirdCost är vad den billigaste tredjedelen av alla köpbara items kostar
// tillsammans – designregeln säger att det ska vara mer än en omgångs
// startkassa, så att man knappt får tag på 33 % under en omgång.
func (c *Catalog) ThirdCost() int {
p := c.Purchasable()
n := (len(p) + 2) / 3
sum := 0
for _, it := range p[:n] {
sum += it.Price
}
return sum
}