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
}

View File

@@ -0,0 +1,262 @@
package progress
import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
// XP: nivå n+1 nås när man samlat 150·n(n+1)/2 XP (150, 450, 900, …).
// Varje level-up ger en skillpoäng.
const XPPerLevelStep = 150
// LevelResult är bästa resultatet på en bana.
type LevelResult struct {
Cleared bool `json:"cleared"`
Stars int `json:"stars"`
Score int `json:"score"`
Attempts int `json:"attempts"`
}
// Progress är det som växer mellan banor.
type Progress struct {
XP int `json:"xp"`
SkillPoints int `json:"skillPoints"`
Skills map[string]bool `json:"skills"`
Items map[string]bool `json:"items"`
Equipped map[string]string `json:"equipped"` // slot -> item-id
Levels map[string]*LevelResult `json:"levels"`
}
// New börjar från noll.
func New() *Progress {
return &Progress{Skills: map[string]bool{}, Items: map[string]bool{}, Equipped: map[string]string{}, Levels: map[string]*LevelResult{}}
}
func (p *Progress) ensure() {
if p.Skills == nil {
p.Skills = map[string]bool{}
}
if p.Items == nil {
p.Items = map[string]bool{}
}
if p.Equipped == nil {
p.Equipped = map[string]string{}
}
if p.Levels == nil {
p.Levels = map[string]*LevelResult{}
}
}
// LevelFor är nivån (från 1) för en XP-summa.
func LevelFor(xp int) int {
n := 0
for XPPerLevelStep*(n+1)*(n+2)/2 <= xp {
n++
}
return n + 1
}
// XPToNext är hur mycket som saknas till nästa nivå.
func XPToNext(xp int) int {
n := LevelFor(xp) // nästa nivå är n+1, som kräver 150·n(n+1)/2
return XPPerLevelStep*n*(n+1)/2 - xp
}
// XPFor ger XP för en klarad bana.
func XPFor(score, stars int) int { return score/10 + 25*stars }
// AddXP lägger till XP och ger en skillpoäng per level-up. Returnerar antalet level-ups.
func (p *Progress) AddXP(n int) int {
before := LevelFor(p.XP)
p.XP += max(0, n)
ups := LevelFor(p.XP) - before
p.SkillPoints += ups
return ups
}
// Level är spelarens nivå.
func (p *Progress) Level() int { return LevelFor(p.XP) }
// Has rapporterar om skillen är upplåst.
func (p *Progress) Has(skill string) bool { return p.Skills[skill] }
// CanUnlock: har poängen, kraven uppfyllda och inte redan upplåst.
func (p *Progress) CanUnlock(c *Catalog, id string) bool {
s := c.Skill(id)
if s == nil || p.Has(id) || p.SkillPoints < s.Cost {
return false
}
for _, r := range s.Requires {
if !p.Has(r) {
return false
}
}
return true
}
// Unlock låser upp en skill mot skillpoäng.
func (p *Progress) Unlock(c *Catalog, id string) bool {
if !p.CanUnlock(c, id) {
return false
}
p.ensure()
p.SkillPoints -= c.Skill(id).Cost
p.Skills[id] = true
return true
}
// Own rapporterar om spelaren har itemet.
func (p *Progress) Own(item string) bool { return p.Items[item] }
// Give ger ett item (belöning). Bärs direkt om platsen är ledig. Returnerar
// false om spelaren redan hade det.
func (p *Progress) Give(c *Catalog, id string) bool {
it := c.Item(id)
if it == nil || p.Own(id) {
return false
}
p.ensure()
p.Items[id] = true
if p.Equipped[it.Slot] == "" {
p.Equipped[it.Slot] = id
}
return true
}
// Buy köper ett item kontant via pay. Returnerar false om det inte gick.
func (p *Progress) Buy(c *Catalog, id string, pay func(yen int) bool) bool {
it := c.Item(id)
if it == nil || it.Price <= 0 || p.Own(id) || !pay(it.Price) {
return false
}
return p.Give(c, id)
}
// Equip bär ett ägt item på sin plats (ersätter det som satt där).
func (p *Progress) Equip(c *Catalog, id string) bool {
it := c.Item(id)
if it == nil || !p.Own(id) {
return false
}
p.ensure()
p.Equipped[it.Slot] = id
return true
}
// Unequip tar av det som bärs på platsen.
func (p *Progress) Unequip(slot string) { delete(p.Equipped, slot) }
// Worn är itemet på platsen, eller nil.
func (p *Progress) Worn(c *Catalog, slot string) *Item {
if id := p.Equipped[slot]; id != "" {
return c.Item(id)
}
return nil
}
// Aggregate är de sammanlagda effekterna av skills och burna items.
type Aggregate struct {
Stats sim.Stats
Kana bool
FogRadius int
Landmarks bool
FreeGates bool
Battery bool
Reveal map[string]bool
Tint [3]float32
}
// Effects räknar ihop allt spelaren har.
func (p *Progress) Effects(c *Catalog) Aggregate {
a := Aggregate{Stats: sim.DefaultStats(), Reveal: map[string]bool{}, Tint: [3]float32{1, 1, 1}}
apply := func(e Effect) {
for k, v := range e.Stats {
a.Stats[statIndex[k]] *= v
}
a.Kana = a.Kana || e.Kana
a.FogRadius += e.FogRadius
a.Landmarks = a.Landmarks || e.Landmarks
a.FreeGates = a.FreeGates || e.FreeGates
a.Battery = a.Battery || e.Battery
if e.RevealLevel != "" {
a.Reveal[e.RevealLevel] = true
}
}
for id := range p.Skills {
if s := c.Skill(id); s != nil {
apply(s.Effect)
}
}
for _, slot := range Slots {
if it := p.Worn(c, slot); it != nil {
apply(it.Effect)
if len(it.Tint) == 3 {
for i := range a.Tint {
a.Tint[i] *= float32(it.Tint[i])
}
}
}
}
return a
}
// RecordLevel sparar ett resultat; bästa stjärnor/poäng behålls. Returnerar
// true första gången banan klaras.
func (p *Progress) RecordLevel(id string, won bool, stars, score int) (first bool) {
p.ensure()
r := p.Levels[id]
if r == nil {
r = &LevelResult{}
p.Levels[id] = r
}
r.Attempts++
if !won {
return false
}
first = !r.Cleared
r.Cleared = true
r.Stars = max(r.Stars, stars)
r.Score = max(r.Score, score)
return first
}
// Cleared rapporterar om banan är klarad.
func (p *Progress) Cleared(id string) bool {
r := p.Levels[id]
return r != nil && r.Cleared
}
// Unlocked avgör om en bana går att spela: huvudbana 1 alltid, huvudbana n
// när huvudbana n−1 är klarad, sidobana när föräldern är klarad.
func (p *Progress) Unlocked(all []*level.Level, id string) bool {
var me *level.Level
for _, l := range all {
if l.ID == id {
me = l
}
}
if me == nil {
return false
}
if me.IsSide() {
return p.Cleared(me.Parent)
}
if me.Order <= 1 {
return true
}
for _, l := range all {
if !l.IsSide() && l.Order == me.Order-1 {
return p.Cleared(l.ID)
}
}
return false
}
// TotalStars är summan av bästa stjärnor.
func (p *Progress) TotalStars() int {
n := 0
for _, r := range p.Levels {
n += r.Stars
}
return n
}

View File

@@ -0,0 +1,216 @@
package progress
import (
"math"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
func loadCatalog(t *testing.T) *Catalog {
t.Helper()
sk, err := res.ReadFile("data", "skills.json")
if err != nil {
t.Fatal(err)
}
it, err := res.ReadFile("data", "items.json")
if err != nil {
t.Fatal(err)
}
c, err := LoadCatalog(sk, it)
if err != nil {
t.Fatal(err)
}
return c
}
func TestShippedCatalogAndThirdRule(t *testing.T) {
c := loadCatalog(t)
if len(c.Skills) < 10 || len(c.Items) < 20 {
t.Fatalf("skills=%d items=%d", len(c.Skills), len(c.Items))
}
// Designregeln: billigaste tredjedelen av alla köpbara items kostar mer
// än startkassan, så att man knappt får tag på 33 % under en omgång.
if c.ThirdCost() <= sim.StartCashYen {
t.Fatalf("billigaste tredjedelen kostar %d ≤ startkassan %d", c.ThirdCost(), sim.StartCashYen)
}
// Alla banor som items pekar på finns.
dir, _ := res.Path("data", "levels")
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, l := range all {
ids[l.ID] = true
for _, r := range l.Rewards.Items {
if c.Item(r) == nil {
t.Errorf("%s belönar okänt item %q", l.ID, r)
}
}
for _, s := range l.Shop {
if it := c.Item(s); it == nil || it.Price == 0 {
t.Errorf("%s säljer okänt/gratis item %q", l.ID, s)
}
}
}
for _, it := range c.Items {
if it.Effect.RevealLevel != "" && !ids[it.Effect.RevealLevel] {
t.Errorf("%s avslöjar okänd bana %q", it.ID, it.Effect.RevealLevel)
}
}
}
func TestCatalogValidation(t *testing.T) {
bad := []struct{ name, skills, items string }{
{"okänd stat", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"effect":{"stats":{"mana":2}}}]}`, `{"items":[]}`},
{"okänt krav", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"requires":["zzz"]}]}`, `{"items":[]}`},
{"dubblett", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"}},{"id":"a","cost":1,"name":{"sv":"A","en":"A"}}]}`, `{"items":[]}`},
{"okänd slot", `{"skills":[]}`, `{"items":[{"id":"x","slot":"tail","name":{"sv":"X","en":"X"},"price":1}]}`},
{"trasig tint", `{"skills":[]}`, `{"items":[{"id":"x","slot":"head","name":{"sv":"X","en":"X"},"price":1,"tint":[1,2]}]}`},
}
for _, b := range bad {
if _, err := LoadCatalog([]byte(b.skills), []byte(b.items)); err == nil {
t.Errorf("%s: förväntade fel", b.name)
}
}
}
func TestXPLevelsAndSkillPoints(t *testing.T) {
cases := map[int]int{0: 1, 149: 1, 150: 2, 449: 2, 450: 3, 900: 4, 1500: 5}
for xp, lvl := range cases {
if LevelFor(xp) != lvl {
t.Errorf("LevelFor(%d)=%d, vill ha %d", xp, LevelFor(xp), lvl)
}
}
if XPToNext(0) != 150 || XPToNext(150) != 300 || XPToNext(449) != 1 {
t.Fatalf("XPToNext: %d %d %d", XPToNext(0), XPToNext(150), XPToNext(449))
}
p := New()
if ups := p.AddXP(XPFor(941, 3)); ups != 1 || p.SkillPoints != 1 || p.Level() != 2 {
t.Fatalf("ups=%d points=%d level=%d", ups, p.SkillPoints, p.Level())
}
if ups := p.AddXP(1000); ups != 2 || p.SkillPoints != 3 {
t.Fatalf("två nivåer på en gång: ups=%d points=%d", ups, p.SkillPoints)
}
if p.AddXP(-5) != 0 || p.XP != XPFor(941, 3)+1000 {
t.Fatal("negativ XP ignoreras")
}
}
func TestSkillTreeRequirementsAndCost(t *testing.T) {
c := loadCatalog(t)
p := New()
if p.CanUnlock(c, "focus1") || p.Unlock(c, "focus1") {
t.Fatal("utan poäng går inget")
}
p.SkillPoints = 3
if p.CanUnlock(c, "focus2") {
t.Fatal("focus2 kräver focus1")
}
if !p.Unlock(c, "focus1") || p.SkillPoints != 2 || !p.Has("focus1") {
t.Fatal("focus1 kostar 1")
}
if !p.Unlock(c, "focus2") || p.SkillPoints != 0 {
t.Fatal("focus2 kostar 2 och kräver focus1")
}
if p.Unlock(c, "focus1") {
t.Fatal("inte två gånger")
}
eff := p.Effects(c)
if math.Abs(eff.Stats[sim.StatFocus]-1.25*1.25) > 1e-9 || eff.Kana {
t.Fatalf("effekter: %+v", eff)
}
p.SkillPoints = 5
p.Unlock(c, "kana")
p.Unlock(c, "scout")
p.Unlock(c, "local")
eff = p.Effects(c)
if !eff.Kana || eff.FogRadius != 1 || !eff.Landmarks {
t.Fatalf("flaggor: %+v", eff)
}
}
func TestItemsBuyEquipAndEffects(t *testing.T) {
c := loadCatalog(t)
p := New()
cash := 10000
pay := func(yen int) bool {
if cash < yen {
return false
}
cash -= yen
return true
}
if !p.Buy(c, "sneakers", pay) || !p.Own("sneakers") || p.Equipped["feet"] != "sneakers" || cash != 10000-3500 {
t.Fatalf("köp: own=%v equipped=%v cash=%d", p.Own("sneakers"), p.Equipped, cash)
}
if p.Buy(c, "sneakers", pay) || p.Buy(c, "skin_kimono", pay) || p.Buy(c, "rail_pass", pay) {
t.Fatal("inte igen, inte gratisitems, inte utan råd (8000 > 6500)")
}
if !p.Buy(c, "geta", pay) || p.Equipped["feet"] != "sneakers" {
t.Fatal("andra skor köps men byter inte automatiskt")
}
if !p.Equip(c, "geta") || p.Equipped["feet"] != "geta" {
t.Fatal("Equip byter")
}
eff := p.Effects(c)
if math.Abs(eff.Stats[sim.StatSpeed]-0.9) > 1e-9 || math.Abs(eff.Stats[sim.StatLuck]-1.2) > 1e-9 {
t.Fatalf("geta: %+v", eff.Stats)
}
p.Unequip("feet")
if p.Effects(c).Stats[sim.StatSpeed] != 1 || p.Worn(c, "feet") != nil {
t.Fatal("Unequip")
}
if !p.Give(c, "skin_kimono") || p.Give(c, "skin_kimono") || p.Equipped["body"] != "skin_kimono" {
t.Fatal("belöning ges en gång och bärs om platsen är ledig")
}
eff = p.Effects(c)
if eff.Tint == [3]float32{1, 1, 1} {
t.Fatal("skinnet ska tona spelaren")
}
p.Give(c, "rail_pass")
p.Give(c, "map_shibuya")
p.Equip(c, "map_shibuya") // bara en pryl i väskan åt gången
eff = p.Effects(c)
if eff.FreeGates || !eff.Reveal["003-shibuya"] {
t.Fatalf("väskan: %+v", eff)
}
p.Equip(c, "rail_pass")
if !p.Effects(c).FreeGates {
t.Fatal("JR Pass i väskan ger gratis spärrar")
}
}
func TestLevelRecordsAndUnlocking(t *testing.T) {
dir, _ := res.Path("data", "levels")
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
p := New()
if !p.Unlocked(all, "001-flygplatsen") || p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") || p.Unlocked(all, "003-shibuya") {
t.Fatal("bara första huvudbanan är öppen från start")
}
if p.RecordLevel("001-flygplatsen", false, 0, 0) || p.Cleared("001-flygplatsen") || p.Levels["001-flygplatsen"].Attempts != 1 {
t.Fatal("förlust räknas som försök")
}
if !p.RecordLevel("001-flygplatsen", true, 2, 700) || !p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") {
t.Fatal("första klaring öppnar nästa huvudbana men inte sidobanan under 002")
}
if p.RecordLevel("001-flygplatsen", true, 3, 500) {
t.Fatal("andra klaring är inte första")
}
if r := p.Levels["001-flygplatsen"]; r.Stars != 3 || r.Score != 700 || r.Attempts != 3 {
t.Fatalf("bästa behålls: %+v", r)
}
p.RecordLevel("002-shinjuku", true, 1, 300)
if !p.Unlocked(all, "002b-kabukicho") || !p.Unlocked(all, "003-shibuya") || p.TotalStars() != 4 {
t.Fatalf("efter 002: sidobana och 003 öppna, stjärnor=%d", p.TotalStars())
}
if p.Unlocked(all, "finns-inte") {
t.Fatal("okänd bana")
}
}