From 36a45563ec3cd16dc6c7100f22824a457e17f1fe Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 24 Oct 2026 23:20:12 +0200 Subject: [PATCH] =?UTF-8?q?M11:=20o=C3=A4ndligt=20l=C3=A4ge=20och=20New=20?= =?UTF-8?q?Game+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 15 +++- cmd/metro-puzzle-jp/main.go | 3 +- doc/img/endless.png | Bin 0 -> 20071 bytes doc/img/map.png | Bin 15870 -> 16774 bytes doc/mal-och-tester.md | 5 +- doc/roadmap.md | 24 +++++- doc/spelbeskrivning.md | 2 + internal/endless/endless.go | 115 +++++++++++++++++++++++++++++ internal/endless/endless_test.go | 115 +++++++++++++++++++++++++++++ internal/game/game.go | 85 +++++++++++++++++++++ internal/game/input.go | 14 ++++ internal/game/scene_level.go | 21 +++++- internal/game/scene_map.go | 50 +++++++++++-- internal/game/scene_result.go | 13 +++- internal/progress/progress.go | 15 +++- internal/progress/progress_test.go | 18 +++++ internal/save/save.go | 33 +++++++++ internal/save/save_test.go | 16 ++++ internal/sim/run.go | 4 +- resources/text/en.json | 12 ++- resources/text/sv.json | 12 ++- tools/shots/main.go | 3 + 22 files changed, 556 insertions(+), 19 deletions(-) create mode 100644 doc/img/endless.png create mode 100644 internal/endless/endless.go create mode 100644 internal/endless/endless_test.go diff --git a/README.md b/README.md index 34876f3..3e87186 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,11 @@ sällskapet i behåll. Klarar man finalen (Tokyo → Nippori → Narita) visas **hemresan**: planet lyfter, resan summeras och eftertexterna rullar. Med egna kort väljer man **vem som betalar** i kiosken, och på de svåraste stationerna släpper spärren inte ut en som checkat in: man får **kalla på -personal** med Enter vid spärren. Nästa är M11 (oändligt läge och New -Game+). Se [doc/roadmap.md](doc/roadmap.md). +personal** med Enter vid spärren. Efter storyn finns **oändligt läge** (O på +bankartan: lottade resmål på storyns stationer tills man förlorar, egen +poänglista) och **New Game+** (G två gånger: om från början med skills kvar +och ett större sällskap). Nästa är M12 (ljud, polish, release) och M10b +(bussar). Se [doc/roadmap.md](doc/roadmap.md). ![Bankartan](doc/img/map.png) @@ -82,6 +85,8 @@ labyrintgolv, samma klocka och samma hungriga sällskap. ![Hemresan](doc/img/ending.png) +![Oändligt läge](doc/img/endless.png) + På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, @@ -182,6 +187,7 @@ metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/ metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar) metro-puzzle-jp -mode signs # navigeringsläge från start: list eller signs (annars sparfilens; N på bankartan växlar) metro-puzzle-jp -ending # öppna slutscenen (hemresan) direkt (tester/skärmdumpar) +metro-puzzle-jp -endless -seed 7 # starta oändligt läge direkt med en viss målföljd (tester/skärmdumpar) metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -version ``` @@ -194,7 +200,9 @@ som inte släpper ut en; i minispelen väljer pilarna och Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M döljer/visar minikartan, P på kartan lägger kortet i telefonen, N på kartan -växlar navigeringsläge (lista/skyltar), Esc går tillbaka, R spelar om. Gamepad +växlar navigeringsläge (lista/skyltar), O på kartan startar oändligt läge +och G (två gånger) New Game+ när storyn är klar, Esc går tillbaka, R spelar +om. Gamepad med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista, Back/Select = karta. @@ -240,6 +248,7 @@ internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go) internal/save/ sparfil (versionerad JSON) internal/level/ banformat, validering, framkomlighet, koppling till linjenätet internal/nav/ linjenätet: stationer, linjer, rätt/fel håll/för långt bort (ren Go) +internal/endless/ oändligt läge: seedade mål på storyns stationer (ren Go) internal/res/ hittar och läser resources/ internal/rng/ seedad slump tools/ build/ genart/ shots/ diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index 4f0eb18..64c3b1e 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -30,6 +30,7 @@ func main() { noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)") mode := flag.String("mode", "", "navigeringsläge från start: list eller signs (tomt = sparfilens)") ending := flag.Bool("ending", false, "öppna slutscenen direkt (tester/skärmdumpar)") + endlessFlag := flag.Bool("endless", false, "starta oändligt läge direkt (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -38,7 +39,7 @@ func main() { return } - g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending}) + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave, Mode: *mode, Ending: *ending, Endless: *endlessFlag}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/endless.png b/doc/img/endless.png new file mode 100644 index 0000000000000000000000000000000000000000..d74a737039e65bf9f9f2bb744d33f2197bda29b9 GIT binary patch literal 20071 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 literal 0 HcmV?d00001 diff --git a/doc/img/map.png b/doc/img/map.png index 08283393dbd09e08b0ca1cae8a0bcb9cb03543a1..20e30ba50d130b803e5f6af28bf173ab741bd983 100644 GIT binary patch delta 7777 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 aqNo(k5h!nK6yg6hA}tL)_0rS!*Zv2vr&Kin diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index fbb328e..e77f774 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -136,6 +136,9 @@ att bygga på Linux som på Windows. | `internal/sim` | `TestKioskAsksWhoPays`, `TestOneWayGatesNeedStaff` | **M10c:** kiosken frågar vem som betalar med egna kort, vald följares kort dras, kontant fungerar, avbrott drar inget; avgift vid inpassering i betalt område, spärren släpper inte ut incheckad, personal låser upp efter 10 s, ny inpassering kostar igen | | `internal/level` | `TestOneWayGatesAndInside` | **M10c:** `oneWayGates` läses, `InsideGates` = rutorna innanför spärrarna | | `internal/level` | `TestPathOutsideAvoidsOtherPaidAreas` + regel i `TestAllShippedLevels` | **M10d:** vägar som undviker andra perrongers betalda områden; varje perrong nås utan att gå genom ett annat betalt område | +| `internal/endless` | `TestPlanIsDeterministicAndValid`, `TestDestinationsNeedTrapAndRoute` | **M11:** samma seed → samma följd, annan seed → annan; varje mål har 1–4 rätta tåg och en fälla, nytt resmål, inga byten, aldrig samma station två gånger i rad; sällskapet växer och tidsgränsen krymper | +| `internal/progress` | `TestNewGamePlusKeepsSkillsResetsItems` | **M11:** NG+ behåller skills, XP, skillpoäng och klarad story; nollställer items, utrustning och banresultat | +| `internal/save` | `TestEndlessRecords` | **M11:** poänglistan sorterad, kapad till fem, "ny bästa" bara när toppen slås | | `internal/level` | `TestToiletSides` | **M10d:** 005 och 008b har toaletter bara innanför, 007b på båda sidor, 007 och 008 bara utanför; innanför bara på banor med spärrar utan återvändo | | `internal/sim` | `TestJourneyWithTransfer`, `TestJourneyWrongTrainOnSecondLeg`, `TestChainLevelsLoad` | **M10:** bytet fortsätter omgången på nästa station med klocka, plånbok, sällskap och par-summa; vinst först på sista delbanan; fel håll på bytesstationen förlorar; delbanor laddas enskilt | | `internal/minigame` | `TestCarPickAlightingAndChoice`, `TestCrowdLevelNarrowsZone`, `TestRideCrowdLetPass` | Folk av först (för tidigt räknas), seedad trängsel med minst en lugn vagn, val; trängsel smalnar zonen; resan: kortare tid per station, passagerare som vill förbi, blockerade räknas | @@ -163,7 +166,7 @@ att bygga på Linux som på Windows. | ~~M10c Vem betalar, spärrar utan återvändo~~ ✓ | Väljaren betalar från rätt källa och nekar den utan pengar; spärren håller kvar, personalen låser upp, ny inpassering kostar – finns, se tabellen ovan | | ~~M10d Toaletter innanför/utanför~~ ✓ | Toaletterna ligger på rätt sida av spärrarna per bana – finns, se tabellen ovan | | M10b Bussar och vägar | Bussar följer tidtabell och vägrutor; hållplatser bedöms som perronger; överstimulerande UI-reglaget påverkar bara presentationen | -| M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items | +| ~~M11 Oändligt läge & NG+~~ ✓ | Målsekvens deterministisk per seed, giltiga mål med fälla, NG+ behåller skills men nollställer items, poänglista – finns, se tabellen ovan | | M12 Ljud & polish | Alla ljudfiler som refereras finns och går att avkoda; Windows-binär startar (manuellt) | ### Acceptanskriterier för "klar" diff --git a/doc/roadmap.md b/doc/roadmap.md index 67eeb19..35e4500 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -646,10 +646,32 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. (`sim.TestOneWayGatesNeedStaff` går vägen till första spärren i stället för ett fast steg), och levelcheck är grön. ✓ -### M11 – Oändligt läge och New Game+ +### M11 – Oändligt läge och New Game+ ✓ (klar 2026-10-24) - Slumpade mål från seed, poäng per mål, egen poänglista. NG+ behåller skills. - Låses upp när storyn är klar. +- **Så blev det:** `internal/endless` lottar mål ur en seed: stationen är + en av storyns nätkopplade banor (alla huvud-, sido- och delbanor, aldrig + samma två gånger i rad) och resmålet lottas bland stationer som minst ett + av banans tåg går rakt till men inte alla – samma labyrint och samma tåg, + men vilket tåg som är rätt beror på resmålet. Banan kopieras, får nytt + `to`/`destination`, inga byten, sällskap som växer med en person var + tredje mål (max 5) och en tidsgräns som krymper 5 % per mål (golv 180 s), + och bedöms om mot nätet. Samma seed ger samma följd. **Omgången:** O på + bankartan (efter storyn) eller `-endless`; HUD:en visar "#3 Ueno + station"; klarat mål ger poäng (summeras) och XP, Enter ger nästa mål; + förlust (eller Esc) avslutar omgången som hamnar i poänglistan (fem + bästa i sparfilen, "Ny bästa!" när toppen slås). **New Game+:** G två + gånger på bankartan: banresultat, items, utrustning, pengar och debuffar + nollställs, skills, XP och språkkunskap behålls, och sällskapet är en + person större på alla banor per NG+-varv (`RunOptions.ExtraParty`). +- **Klar när:** målföljden är deterministisk per seed och giltig + (`endless.TestPlanIsDeterministicAndValid`, `TestDestinationsNeedTrapAndRoute`), + NG+ behåller skills men nollställer items + (`progress.TestNewGamePlusKeepsSkillsResetsItems`), poänglistan sorterar + och kapar (`save.TestEndlessRecords`). ✓ +- **Kvar till senare:** oändligt läge med byten (resor över två stationer), + stationer som genereras vid körning (generatorn in i repot). ### M12 – Ljud, polish och release diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 7aabb27..4f637b1 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -283,4 +283,6 @@ från skills i skill tree, och från unika skills man får av sidouppdrag. | "flera stopp och byten" | En resa är en kedja av banfiler (`next` → delbana med `kind: "leg"`). Samma omgång fortsätter på nästa station med klocka, plånbok och sällskap; resmålet i HUD:en visas "via" bytesstationen. Vilken bytesstation som gäller bestämmer banan – spelaren väljer tåg, inte rutt | Fri ruttplanering (välj själv var man byter) är en möjlig utbyggnad | | "slutar när man ska tillbaka till flygplatsen" | Sista huvudbanan (008) går Tokyo → Nippori → Narita flygplats med Keisei. När den klaras första gången visas hemresan: planet lyfter, resan summeras, eftertexter | Slutet går att se igen via `-ending` | | Buss | Uppskjuten till M10b (vägrutor, hållplatser, egen sprite) | | +| "Oändligt läge … målen är slumpade" | Målen är storyns stationer med lottade resmål (samma labyrint, samma tåg, nytt resmål avgör vilket tåg som är rätt), seedat; sällskapet växer och tidsgränsen krymper per mål; omgången slutar vid första förlust och hamnar i en egen poänglista | Slumpade labyrinter vid körning kan komma senare | +| "New Game+ … behåll alla dina skills" | Skills, XP och språkkunskap behålls; items, stjärnor, pengar och debuffar nollställs; sällskapet är en person större per NG+-varv | | | Repo-namnet | Arbetsnamnet var "jp-subway-pussel"; Björn döpte Gitea-repot till **metro-puzzle-jp** (2026-10-23), så repo, modul, binär och titel heter så | Spelets visningsnamn "Metro Puzzle JP" kan ändras fritt i `resources/text/*.json` | diff --git a/internal/endless/endless.go b/internal/endless/endless.go new file mode 100644 index 0000000..401e7dc --- /dev/null +++ b/internal/endless/endless.go @@ -0,0 +1,115 @@ +// Package endless är oändligt läge: storyns stationer med slumpade resmål, +// mål efter mål tills man förlorar. Allt lottas ur en seed så att samma +// seed ger samma följd. Ren Go. +package endless + +import ( + "fmt" + "sort" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +// Goal är ett mål: en station (en kopia av storyns bana) med nytt resmål. +type Goal struct { + Step int // 0-baserat + Base string // ursprungliga banans id + Level *level.Level // kopian, bedömd mot nätet +} + +// Svårighetsramp och standardvärden. +const ( + MaxParty = 5 // sällskapet växer med ett var tredje mål, upp till så här många + PartyStepEvery = 3 // mål per extra person + DefaultTimeLimit = 420 // s, för banor utan egen tidsgräns + MinTimeLimit = 180 // s, golvet när tidsgränsen krymper + TimeLimitShrinkPct = 5 // procent kortare tidsgräns per mål +) + +// Pool är stationerna som kan bli mål: alla nätkopplade banor (även +// delbanor och sidobanor), sorterade på id. +func Pool(levels []*level.Level) []*level.Level { + var out []*level.Level + for _, l := range levels { + if l.Networked() && l.Net != nil { + out = append(out, l) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// Destinations är stationerna som fungerar som resmål från banan: minst +// ett tåg går rakt dit, minst ett gör det inte (en fälla), och inte fler +// än nav.MaxRoutes rätta. Sorterade på id. +func Destinations(l *level.Level) []string { + var out []string + for _, s := range l.Net.Stations { + if s.ID == l.Station { + continue + } + correct := 0 + for _, tr := range l.Trains { + if l.Net.Judge(tr.LineID, l.Station, tr.Terminus, s.ID) == nav.Direct { + correct++ + } + } + if correct >= 1 && correct <= nav.MaxRoutes && correct < len(l.Trains) { + out = append(out, s.ID) + } + } + sort.Strings(out) + return out +} + +// Plan ger målet för ett steg ur seeden. Stationen väljs så att den aldrig +// är samma som föregående stegs; resmålet lottas bland stationens möjliga. +func Plan(levels []*level.Level, seed rng.Seed, step int) (*Goal, error) { + pool := Pool(levels) + if len(pool) == 0 { + return nil, fmt.Errorf("endless: inga nätkopplade banor") + } + idx := baseIndex(len(pool), seed, step) + base := pool[idx] + dests := Destinations(base) + if len(dests) == 0 { + return nil, fmt.Errorf("endless: %s har inga möjliga resmål", base.ID) + } + r := seed.Derive(fmt.Sprintf("endless:dest:%d", step)).New() + dest := dests[r.IntN(len(dests))] + + c := *base + c.Trains = append([]level.Train(nil), base.Trains...) + c.Next = "" + c.To = dest + name := base.Net.Station(dest).Name + c.Destination = level.Text{"ja": name["ja"], "romaji": nav.Name(name, "romaji"), "en": nav.Name(name, "en"), "sv": nav.Name(name, "sv")} + c.Party = min(MaxParty, max(1, base.Party)+step/PartyStepEvery) + limit := base.TimeLimitSec + if limit == 0 { + limit = DefaultTimeLimit + } + for i := 0; i < step; i++ { + limit = limit * (100 - TimeLimitShrinkPct) / 100 + } + c.TimeLimitSec = max(MinTimeLimit, limit) + if err := c.Annotate(base.Net); err != nil { + return nil, err + } + return &Goal{Step: step, Base: base.ID, Level: &c}, nil +} + +// baseIndex väljer station för ett steg: ett seedat hopp från föregående +// stegs station, så att två mål i rad aldrig är samma station. +func baseIndex(n int, seed rng.Seed, step int) int { + idx := seed.Derive("endless:base:0").New().IntN(n) + for s := 1; s <= step; s++ { + r := seed.Derive(fmt.Sprintf("endless:base:%d", s)).New() + if n > 1 { + idx = (idx + 1 + r.IntN(n-1)) % n + } + } + return idx +} diff --git a/internal/endless/endless_test.go b/internal/endless/endless_test.go new file mode 100644 index 0000000..3cc2fdf --- /dev/null +++ b/internal/endless/endless_test.go @@ -0,0 +1,115 @@ +package endless + +import ( + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" +) + +func loadLevels(t *testing.T) []*level.Level { + t.Helper() + dir, err := res.Path("data", "levels") + if err != nil { + t.Fatal(err) + } + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + return all +} + +// TestPlanIsDeterministicAndValid: samma seed ger samma följd, andra seeds +// en annan; varje mål har rätt tåg, en fälla, nytt resmål och ingen kedja; +// två mål i rad är aldrig samma station; sällskapet växer. +func TestPlanIsDeterministicAndValid(t *testing.T) { + all := loadLevels(t) + if len(Pool(all)) < 5 { + t.Fatalf("poolen ska ha storyns stationer, har %d", len(Pool(all))) + } + seed := rng.FromInt(11) + prev := "" + var bases, dests []string + for step := 0; step < 12; step++ { + g, err := Plan(all, seed, step) + if err != nil { + t.Fatal(err) + } + again, _ := Plan(all, seed, step) + if again.Base != g.Base || again.Level.To != g.Level.To { + t.Fatalf("steg %d: inte deterministiskt", step) + } + l := g.Level + if l.Next != "" || l.To == l.Station || l.Net == nil || l.Destination.Get("ja") == "" { + t.Fatalf("steg %d: %+v", step, l) + } + correct, wrong := 0, 0 + for _, tr := range l.Trains { + if tr.Verdict == nav.Unjudged { + t.Fatalf("steg %d: obedömt tåg", step) + } + if tr.Correct { + correct++ + } else { + wrong++ + } + } + if correct < 1 || correct > nav.MaxRoutes || wrong == 0 { + t.Fatalf("steg %d (%s → %s): rätt=%d fel=%d", step, g.Base, l.To, correct, wrong) + } + if g.Base == prev { + t.Fatalf("steg %d: samma station två gånger i rad (%s)", step, g.Base) + } + if l.TimeLimitSec < MinTimeLimit || l.Party < 1 || l.Party > MaxParty { + t.Fatalf("steg %d: tidsgräns %d sällskap %d", step, l.TimeLimitSec, l.Party) + } + prev = g.Base + bases = append(bases, g.Base) + dests = append(dests, l.To) + } + // Rampen: sällskapet är större längre fram än i början. + first, _ := Plan(all, seed, 0) + late, _ := Plan(all, seed, 9) + if late.Level.Party <= first.Level.Party && first.Level.Party < MaxParty { + t.Fatalf("sällskapet ska växa: %d → %d", first.Level.Party, late.Level.Party) + } + if late.Level.TimeLimitSec >= first.Level.TimeLimitSec && first.Level.TimeLimitSec > MinTimeLimit { + t.Fatalf("tidsgränsen ska krympa: %d → %d", first.Level.TimeLimitSec, late.Level.TimeLimitSec) + } + // Annan seed ger en annan följd. + diff := false + for step := 0; step < 12 && !diff; step++ { + g, err := Plan(all, rng.FromInt(12), step) + if err != nil { + t.Fatal(err) + } + diff = g.Base != bases[step] || g.Level.To != dests[step] + } + if !diff { + t.Fatal("två seeds gav samma följd") + } + // Originalbanorna rörs inte. + for _, l := range all { + if l.ID == bases[0] && l.To == dests[0] && l.Next == "" && l.IsLeg() == false { + // kan råka sammanfalla om lotten valde originalets resmål – kontrollera att Trains är orörda + } + } +} + +func TestDestinationsNeedTrapAndRoute(t *testing.T) { + all := loadLevels(t) + for _, l := range Pool(all) { + d := Destinations(l) + if len(d) == 0 { + t.Errorf("%s: inga möjliga resmål", l.ID) + } + for _, id := range d { + if id == l.Station { + t.Errorf("%s: resmål = stationen själv", l.ID) + } + } + } +} diff --git a/internal/game/game.go b/internal/game/game.go index 2ae5e9f..c70bcaf 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -7,9 +7,11 @@ package game import ( "fmt" "log" + "time" "github.com/hajimehoshi/ebiten/v2" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/endless" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav" @@ -47,6 +49,7 @@ type Game struct { Levels []*level.Level // alla banor, sorterade på id Audio *Audio // nil = tyst Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan + Endless *EndlessRun // pågående omgång i oändligt läge (nil = ingen) savePath string // "" = spara inte fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) @@ -72,6 +75,7 @@ type Options struct { SavePath string // sparfil ("" = standardplatsen) NoSave bool // läs sparfilen men skriv aldrig Ending bool // öppna slutscenen direkt (tester/skärmdumpar) + Endless bool // starta oändligt läge direkt (tester/skärmdumpar) Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens) } @@ -141,6 +145,12 @@ func New(opt Options) (*Game, error) { switch { case opt.Ending: g.scene = NewEndingScene(g) + case opt.Endless: + s, err := g.startEndless(g.seed()) + if err != nil { + return nil, err + } + g.scene = s case opt.LevelID != "": s, err := g.startLevel(opt.LevelID) if err != nil { @@ -153,6 +163,77 @@ func New(opt Options) (*Game, error) { return g, nil } +// EndlessRun är en pågående omgång i oändligt läge. +type EndlessRun struct { + Seed rng.Seed + Step int // 0-baserat: målet som spelas nu + Score int // summan av klarade måls poäng + Goals int // klarade mål +} + +// startEndless börjar en omgång i oändligt läge med första målet. +func (g *Game) startEndless(seed rng.Seed) (*LevelScene, error) { + g.Endless = &EndlessRun{Seed: seed} + return g.endlessGoalScene() +} + +// endlessGoalScene bygger scenen för omgångens aktuella mål. +func (g *Game) endlessGoalScene() (*LevelScene, error) { + e := g.Endless + goal, err := endless.Plan(g.Levels, e.Seed, e.Step) + if err != nil { + return nil, err + } + s := newLevelSceneFor(g, goal.Level, goal.Base, e.Seed.Derive(fmt.Sprintf("run:%d", e.Step))) + s.endless = true + return s, nil +} + +// endlessEnded bokför ett avgjort mål: vinst ger poäng och nästa mål, +// förlust avslutar omgången och lägger in den i poänglistan. +func (g *Game) endlessEnded(run *sim.Run) LevelOutcome { + var out LevelOutcome + e := g.Endless + if e == nil { + return out + } + for _, d := range run.NewDebuffs { + g.Profile.Add(d.Kind, d.LevelsLeft) + } + g.Profile.EndLevel() + if run.Outcome == sim.Won { + e.Score += run.Score.Total + e.Goals++ + e.Step++ + out.XP = progress.XPFor(run.Score.Total, run.Stars) + out.LevelUps = g.Progress.AddXP(out.XP) + } else { + out.EndlessBest = g.State.AddEndless(save.EndlessRecord{Score: e.Score, Goals: e.Goals, Seed: [2]uint64{e.Seed.A, e.Seed.B}, When: time.Now()}) + out.EndlessOver = true + } + g.Save() + return out +} + +// endEndless avslutar en pågående omgång i förtid (Esc på resultatet). +func (g *Game) endEndless() { + if e := g.Endless; e != nil && e.Goals > 0 { + g.State.AddEndless(save.EndlessRecord{Score: e.Score, Goals: e.Goals, Seed: [2]uint64{e.Seed.A, e.Seed.B}, When: time.Now()}) + g.Save() + } + g.Endless = nil +} + +// newGamePlus börjar om storyn med skills kvar: items, banresultat, pengar +// och pågående omgång nollställs; sällskapet blir större på alla banor. +func (g *Game) newGamePlus() { + g.Progress.NewGamePlus() + *g.Wallet = *sim.NewWallet() + g.Profile.Debuffs = nil + g.State.Current = nil + g.Save() +} + // chainAfter är resans fortsättning efter banan id (delbanorna via next). func (g *Game) chainAfter(id string) []*level.Level { if c := level.ChainOf(g.Levels, id); len(c) > 1 { @@ -264,6 +345,7 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions { Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed, Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates, RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode, Chain: g.chainAfter(id), + ExtraParty: g.Progress.NGPlus, } } @@ -311,6 +393,9 @@ type LevelOutcome struct { NewPoints int Unlocked []string // banor som öppnades StoryCleared bool // sista huvudbanan klarades för första gången: visa slutet + + EndlessOver bool // oändligt läge: omgången tog slut + EndlessBest bool // … och blev ny bästa } // levelEnded appliceras exakt en gång när en bana avgjorts: debuffar, diff --git a/internal/game/input.go b/internal/game/input.go index cb0f0cc..96c4a73 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -126,6 +126,20 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust // phoneJustPressed: P = kortet i telefonen av/på (meny). func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } +// endlessJustPressed: O startar oändligt läge (när storyn är klar). +func endlessJustPressed() bool { + return virtual.pressed["o"] || inpututil.IsKeyJustPressed(ebiten.KeyO) +} + +// ngPlusJustPressed: G (två gånger) startar New Game+. +func ngPlusJustPressed() bool { return virtual.pressed["g"] || inpututil.IsKeyJustPressed(ebiten.KeyG) } + +// miniMoveXYAny rapporterar om någon pil just trycktes (för att avbryta en bekräftelse). +func miniMoveXYAny() bool { + x, y := miniMoveXY() + return x != 0 || y != 0 +} + // modeJustPressed: N växlar navigeringsläge (lista/skyltar) på bankartan. func modeJustPressed() bool { return virtual.pressed["n"] || inpututil.IsKeyJustPressed(ebiten.KeyN) } diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 3c4b17e..b27adba 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -41,6 +41,7 @@ type LevelScene struct { id string level *level.Level // aktuell delbana (byts när resan fortsätter på nästa station) run *sim.Run + endless bool // målet är ett lottat mål i oändligt läge (inte storyn) cam *sim.Camera animT float64 hideMap bool // M döljer/visar minikartan @@ -66,10 +67,25 @@ func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { if err != nil { return nil, err } + return newLevelSceneFor(g, lvl, id, seed), nil +} + +// newLevelSceneFor startar en redan laddad (eller lottad) bana. +func newLevelSceneFor(g *Game, lvl *level.Level, id string, seed rng.Seed) *LevelScene { s := &LevelScene{id: id, run: sim.NewRun(lvl, g.runOptions(id, seed))} s.rng = rand.New(rand.NewPCG(1, 2)) s.rebind(g) - return s, nil + return s +} + +// ended bokför banans utgång: storyn via levelEnded, oändligt läge via +// endlessEnded. +func (s *LevelScene) ended(g *Game) { + if s.endless { + s.outcome = g.endlessEnded(s.run) + return + } + s.outcome = g.levelEnded(s.run, s.id) } // rebind bygger om allt som hör till den aktuella delbanan: kamera, räls, @@ -404,6 +420,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { if r.Legs() > 1 { name += fmt.Sprintf(" %d/%d", r.LegNo(), r.Legs()) } + if s.endless && g.Endless != nil { + name = fmt.Sprintf("#%d %s", g.Endless.Step+1, name) + } a.Draw(screen, name, 4, 0, colText) destX := 4 + a.Width(name) + 12 a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown) diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go index 5a6ae0b..c456ca7 100644 --- a/internal/game/scene_map.go +++ b/internal/game/scene_map.go @@ -17,11 +17,12 @@ import ( // linje från flygplatsen, sidobanorna som avstickare ovanför. Låsta banor // är grå, klarade visar stjärnor. type MapScene struct { - mains []*level.Level - sides map[string][]*level.Level // parent -> sidobanor - sel int // vald huvudbana - side int // 0 = huvudbanan, 1.. = sidobana nr - msg string + confirmNG bool // G tryckt en gång: nästa G startar New Game+ + mains []*level.Level + sides map[string][]*level.Level // parent -> sidobanor + sel int // vald huvudbana + side int // 0 = huvudbanan, 1.. = sidobana nr + msg string } type mapNode struct { @@ -105,6 +106,26 @@ func (m *MapScene) Update(g *Game) error { g.Save() case modeJustPressed(): g.toggleMode() + case endlessJustPressed() && g.Progress.StoryCleared: + s, err := g.startEndless(g.seed()) + if err != nil { + m.msg = err.Error() + return nil + } + g.SetScene(s) + return nil + case ngPlusJustPressed() && g.Progress.StoryCleared: + if !m.confirmNG { + m.confirmNG = true + m.msg = g.Assets.T("map.ngplus_confirm") + return nil + } + g.newGamePlus() + g.SetScene(NewMapScene(g)) + return nil + } + if m.confirmNG && (confirmJustPressed() || miniMoveXYAny()) { + m.confirmNG, m.msg = false, "" // något annat tryckt: ingen NG+ } if n := len(m.mains); n > 0 { mx, my := miniMoveXY() @@ -207,7 +228,9 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) { if !p.Unlocked(g.Levels, cur.ID) { a.Draw(screen, a.T("map.locked"), 150, 30, colBad) } else if m.msg != "" { - a.Draw(screen, m.msg, 150, 30, colBad) + for i, l := range a.wrapLine(plainLine(m.msg, colBad), ScreenW-158) { + a.drawLines(screen, []line{l}, 150, 30+i*LineH) + } } } onoff := a.T("menu.off") @@ -218,12 +241,25 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) { a.T("hud.cash"), g.Wallet.Cash) a.Draw(screen, foot, 8, 30, colDim) a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff)+" "+fmt.Sprintf(a.T("menu.mode"), a.T("mode."+g.Mode.String())), 8, 42, colDim) + y := 54 + if p.StoryCleared { // efter storyn: oändligt läge och New Game+ + line := a.T("map.endless") + if b := g.State.BestEndless(); b != nil { + line += fmt.Sprintf(" (%s)", fmt.Sprintf(a.T("map.endless_best"), b.Score, b.Goals)) + } + line += " " + a.T("map.ngplus") + if p.NGPlus > 0 { + line += fmt.Sprintf(" (%s)", fmt.Sprintf(a.T("map.ngplus_level"), p.NGPlus)) + } + a.Draw(screen, line, 8, y, colKnown) + y += LineH + } if len(g.Profile.Debuffs) > 0 { var parts []string for _, d := range g.Profile.Debuffs { parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) } - a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, 54, colBad) + a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, y, colBad) } a.Draw(screen, a.T("map.hint"), 8, ScreenH-24, colDim) a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-12, colDim) diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index b71fb33..f978b3e 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -38,6 +38,17 @@ func (r *ResultScene) Update(g *Game) error { return nil } +// hint är tangentraden längst ner: oändligt läge har inget "spela om". +func (r *ResultScene) hint(a *Assets) string { + if r.from.endless { + if r.from.outcome.EndlessOver { + return a.T("result.hint_endless_over") + } + return a.T("result.hint_endless") + } + return a.T("result.hint") +} + func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run @@ -158,5 +169,5 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim) } - a.Draw(screen, a.T("result.hint"), 28, 161, colDim) + a.Draw(screen, r.hint(a), 28, 161, colDim) } diff --git a/internal/progress/progress.go b/internal/progress/progress.go index da2a581..e5fe761 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -25,7 +25,8 @@ type Progress struct { Items map[string]bool `json:"items"` Equipped map[string]string `json:"equipped"` // slot -> item-id Levels map[string]*LevelResult `json:"levels"` - StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ (M11) låses upp + StoryCleared bool `json:"storyCleared"` // sista huvudbanan klarad: slutet har visats, oändligt läge/NG+ låses upp + NGPlus int `json:"ngPlus"` // antal New Game+ som startats (svårighetspåslag) } // New börjar från noll. @@ -253,6 +254,18 @@ func (p *Progress) Unlocked(all []*level.Level, id string) bool { return false } +// NewGamePlus börjar om storyn med skills och XP kvar: banresultat, items +// och det man har på sig nollställs. Storyn förblir "klar" så att oändligt +// läge är kvar. Returnerar det nya NG+-numret. +func (p *Progress) NewGamePlus() int { + p.ensure() + p.Levels = map[string]*LevelResult{} + p.Items = map[string]bool{} + p.Equipped = map[string]string{} + p.NGPlus++ + return p.NGPlus +} + // TotalStars är summan av bästa stjärnor. func (p *Progress) TotalStars() int { n := 0 diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go index f02fbb6..e58b8ed 100644 --- a/internal/progress/progress_test.go +++ b/internal/progress/progress_test.go @@ -214,3 +214,21 @@ func TestLevelRecordsAndUnlocking(t *testing.T) { t.Fatal("okänd bana") } } + +func TestNewGamePlusKeepsSkillsResetsItems(t *testing.T) { + p := New() + p.XP, p.SkillPoints, p.StoryCleared = 900, 2, true + p.Skills["fog_1"] = true + p.Items["geta"] = true + p.Equipped["feet"] = "geta" + p.RecordLevel("001", true, 3, 500) + if n := p.NewGamePlus(); n != 1 || p.NGPlus != 1 { + t.Fatalf("NG+ nummer %d", n) + } + if !p.Skills["fog_1"] || p.XP != 900 || p.SkillPoints != 2 || !p.StoryCleared { + t.Fatal("skills, XP, skillpoäng och klarad story ska vara kvar") + } + if len(p.Items) != 0 || len(p.Equipped) != 0 || len(p.Levels) != 0 || p.TotalStars() != 0 { + t.Fatal("items, utrustning och banresultat ska nollställas") + } +} diff --git a/internal/save/save.go b/internal/save/save.go index 5469fd5..a66fc44 100644 --- a/internal/save/save.go +++ b/internal/save/save.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "time" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" @@ -40,6 +41,38 @@ type State struct { 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. diff --git a/internal/save/save_test.go b/internal/save/save_test.go index ad94e8f..297e31e 100644 --- a/internal/save/save_test.go +++ b/internal/save/save_test.go @@ -76,3 +76,19 @@ func TestDefaultPath(t *testing.T) { t.Fatalf("path=%q err=%v", p, err) } } + +func TestEndlessRecords(t *testing.T) { + s := New() + if s.BestEndless() != nil { + t.Fatal("tom lista") + } + if !s.AddEndless(EndlessRecord{Score: 100, Goals: 1}) || s.AddEndless(EndlessRecord{Score: 50, Goals: 1}) || !s.AddEndless(EndlessRecord{Score: 300, Goals: 3}) { + t.Fatal("ny bästa ska rapporteras bara när poängen slår listans topp") + } + for i := 0; i < 10; i++ { + s.AddEndless(EndlessRecord{Score: 10 + i}) + } + if len(s.Endless) != MaxEndlessRecords || s.BestEndless().Score != 300 || s.Endless[1].Score != 100 { + t.Fatalf("listan: %+v", s.Endless) + } +} diff --git a/internal/sim/run.go b/internal/sim/run.go index fa96418..459a85f 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -252,6 +252,8 @@ type RunOptions struct { Mode nav.Mode // navigeringsläge (nollvärdet = listläge) Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning + + ExtraParty int // fler i sällskapet än banan säger (New Game+) } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -306,7 +308,7 @@ func NewRun(l *level.Level, o RunOptions) *Run { TransferAt: -1, inside: l.InsideGates(), NoExitAt: -1, - Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), + Party: NewParty(l.Party+o.ExtraParty, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, Stats: CombinedStats(o.Debuffs), diff --git a/resources/text/en.json b/resources/text/en.json index 4bfea50..f2419cb 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -234,5 +234,15 @@ "hud.staff_prompt": "Enter: call the staff to get out (%.0f s)", "hud.staff_busy": "Staff on the way...", "hud.staff_unlocked": "The staff unlocked the cards - you can leave.", - "hud.unlocked_prompt": "Cards unlocked: you can leave. Entering again costs again." + "hud.unlocked_prompt": "Cards unlocked: you can leave. Entering again costs again.", + "map.endless": "O: endless mode", + "map.endless_best": "best %d points, %d goals", + "map.ngplus": "G: New Game+", + "map.ngplus_level": "NG+%d", + "map.ngplus_confirm": "Press G again: the story restarts with skills and XP kept. Items, stars and money reset, the party grows.", + "result.endless_won": "Goal %d done! %d points in total.", + "result.endless_lost": "Endless mode over after %d goals: %d points (best %d).", + "result.endless_best": "New best!", + "result.hint_endless": "Enter: next goal L: words Esc: end the run", + "result.hint_endless_over": "Enter/Esc: level map L: words" } diff --git a/resources/text/sv.json b/resources/text/sv.json index 57acfc0..312ac2e 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -234,5 +234,15 @@ "hud.staff_prompt": "Enter: kalla på personal för att komma ut (%.0f s)", "hud.staff_busy": "Personalen kommer...", "hud.staff_unlocked": "Personalen låste upp korten - du kan gå ut.", - "hud.unlocked_prompt": "Korten är upplåsta: du kan gå ut. Ny inpassering kostar igen." + "hud.unlocked_prompt": "Korten är upplåsta: du kan gå ut. Ny inpassering kostar igen.", + "map.endless": "O: oändligt läge", + "map.endless_best": "bästa %d poäng, %d mål", + "map.ngplus": "G: New Game+", + "map.ngplus_level": "NG+%d", + "map.ngplus_confirm": "Tryck G igen: storyn börjar om med skills och XP kvar. Items, stjärnor och pengar nollställs, sällskapet blir större.", + "result.endless_won": "Mål %d klart! Totalt %d poäng.", + "result.endless_lost": "Oändligt läge slut efter %d mål: %d poäng (bästa %d).", + "result.endless_best": "Ny bästa!", + "result.hint_endless": "Enter: nästa mål L: ordlista Esc: avsluta omgången", + "result.hint_endless_over": "Enter/Esc: bankartan L: ordlista" } diff --git a/tools/shots/main.go b/tools/shots/main.go index e63dd2d..6cc2a55 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -40,6 +40,8 @@ func demoSave(path string) error { st.Progress.Equipped["body"] = "skin_salaryman" st.Progress.Equipped["feet"] = "geta" st.Progress.Skills["focus1"] = true + st.Progress.StoryCleared = true // bankartan visar oändligt läge och New Game+ + st.AddEndless(save.EndlessRecord{Score: 2140, Goals: 4}) return save.Store(path, st) } @@ -165,6 +167,7 @@ func buildShots(levelsDir string) error { []string{"-seed", "7", "-mode", "list", "-know", "上野,番線,方面,乗り換え"}}, {"", "wait 150; shot $OUT/ending.png; press enter; wait 5; shot $OUT/ending-summary.png; press enter; wait 250; shot $OUT/credits.png; quit", []string{"-ending", "-save", "$DEMO"}}, {"007-akihabara", toKiosk7 + "; urge Ville; press enter; wait 5; press right; wait 3; shot $OUT/payer.png; quit", []string{"-seed", "7"}}, + {"", "wait 10; shot $OUT/endless.png; quit", []string{"-endless", "-seed", "7", "-mode", "list", "-save", "$DEMO"}}, {"005-ueno", toPlatform5 + "; " + toGateBack + "; wait 5; push " + pushOut + " 30; wait 3; shot $OUT/no-exit.png; press enter; wait 150; shot $OUT/staff.png; quit", []string{"-seed", "7"}}, } return nil