diff --git a/README.md b/README.md index f805b11..19e6384 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Storyn följer några polare på semester i Japan, från landningen på flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). -**Status:** M6 klar (2026-10-24) – japanska skyltar och ordinlärning, -lämmeltåget med sina behov, minikarta med dimma, och nu pengarna: tågkortet -fylls på vid automaten, svårare banor kräver rätt pappersbiljett, kortet kan -ligga i telefonen (snabbare, men batteri och omskanningar), fulla tåg kräver -att man tränger sig på, och på tåget måste man kliva av på rätt station. -Nästa är M7: svårighet, tid, poäng och debuffar. Se +**Status:** M7 klar (2026-10-24) – tre banor med stigande svårighet. +Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med +dimma, tågkort, biljetter och minispel, och nu svårigheten: tidsgräns, max +antal missade tåg, en poängformel, debuffar som följer med mellan banor, och +inställningarna egna kort per person och sittplatsval med sällskapets +önskemål. Nästa är M7b: tågen ska synas och kännas när de kommer och går. Se [doc/roadmap.md](doc/roadmap.md). ![Skylt på japanska med delvis översättning](doc/img/sign.png) @@ -39,6 +39,13 @@ på japanska. ![Automaten](doc/img/ticket.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, +eller bredvid en viss person. Missnöjda blir sura och drar ned poängen. + +![Sittplatsval](doc/img/seats.png) + Ditt sällskap följer efter i en rad. Mätarna uppe till höger visar mat, dryck och toa för var och en; faller de får någon ett behov som saktar ned er alla tills ni hittar en kiosk eller en toalett. Minikartan i hörnet visar @@ -124,6 +131,7 @@ metro-puzzle-jp -smoke 90 # öppna fönster, avsluta efter 90 bil metro-puzzle-jp -know 改札,番線 -points 3 # förkunskap och språkpoäng (tester/skärmdumpar) metro-puzzle-jp -seed 7 # fast seed för händelser, läsning och minispel (tester/skärmdumpar) metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar) +metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (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 ``` diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index c7f4521..c146010 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -24,6 +24,7 @@ func main() { seed := flag.Uint64("seed", 0, "fast seed för banorna, 0 = slumpad (tester/skärmdumpar)") phone := flag.Bool("phone", false, "ha tågkortet i telefonen från start") card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)") + debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -32,7 +33,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}) + 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)}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/level-001.png b/doc/img/level-001.png index 219975f..75b532a 100644 Binary files a/doc/img/level-001.png and b/doc/img/level-001.png differ diff --git a/doc/img/level-002.png b/doc/img/level-002.png index 50d6f11..8560597 100644 Binary files a/doc/img/level-002.png and b/doc/img/level-002.png differ diff --git a/doc/img/menu.png b/doc/img/menu.png index 321ed41..2c2c0ef 100644 Binary files a/doc/img/menu.png and b/doc/img/menu.png differ diff --git a/doc/img/platform.png b/doc/img/platform.png index 1854c7d..487f539 100644 Binary files a/doc/img/platform.png and b/doc/img/platform.png differ diff --git a/doc/img/result.png b/doc/img/result.png index f08306f..99a071b 100644 Binary files a/doc/img/result.png and b/doc/img/result.png differ diff --git a/doc/img/ride.png b/doc/img/ride.png index 91d80ad..030eeea 100644 Binary files a/doc/img/ride.png and b/doc/img/ride.png differ diff --git a/doc/img/seats.png b/doc/img/seats.png new file mode 100644 index 0000000..fc9e8d4 Binary files /dev/null and b/doc/img/seats.png differ diff --git a/doc/img/sign-help.png b/doc/img/sign-help.png index 8399f21..2c0baa3 100644 Binary files a/doc/img/sign-help.png and b/doc/img/sign-help.png differ diff --git a/doc/img/sign-learned.png b/doc/img/sign-learned.png index 5fa66c8..cb87dd5 100644 Binary files a/doc/img/sign-learned.png and b/doc/img/sign-learned.png differ diff --git a/doc/img/sign.png b/doc/img/sign.png index 431b1ad..21a02c6 100644 Binary files a/doc/img/sign.png and b/doc/img/sign.png differ diff --git a/doc/img/ticket.png b/doc/img/ticket.png index a65bc23..740b08d 100644 Binary files a/doc/img/ticket.png and b/doc/img/ticket.png differ diff --git a/doc/img/topup.png b/doc/img/topup.png index 07117f0..70ac398 100644 Binary files a/doc/img/topup.png and b/doc/img/topup.png differ diff --git a/doc/img/words.png b/doc/img/words.png index a18e791..8332044 100644 Binary files a/doc/img/words.png and b/doc/img/words.png differ diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index 55c6a55..78cc964 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | -| Banor | 100 % passerar det automatiska lösbarhetstestet | 2 av 2 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 3 av 3 | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | @@ -111,6 +111,11 @@ att bygga på Linux som på Windows. | `internal/sim` | `TestWalletPayTopUpAndPhone` | Kort, kontanter, påfyllning, spenderat, batteri gör kortet i telefonen oanvändbart | | `internal/sim` | `TestTopUpFlowAtMachine`, `TestTicketLevelFlow`, `TestCrowdFailureMissesTrain` | Tomt kort → automaten → påfyllning → spärren släpper; **hela bana 002 headless** med fel/rätt biljett, trängsel och resa; två bom = missat tåg | | `internal/sim` | `TestPhoneSpeedRescanAndBattery`, `TestRideWrongOrMissedStopLoses` | Fartbuff, seedad omskanning som fryser spelaren, dött batteri stoppar spärren; fel station / åka förbi förlorar | +| `internal/sim` | `TestDebuffEffectsAndProfile` | **M7:** varje debuff påverkar något, kombineras multiplikativt, profilen förlänger i stället för dubblerar och räknar ned per bana | +| `internal/sim` | `TestTimeLimitAndMissedTrainsLose`, `TestScoreBreakdown` | Tidsgräns och maxantal missade tåg förlorar banan (001 har inga gränser); poängformeln och avdragen | +| `internal/sim` | `TestDebuffsAffectRunAndAreEarned` | Skoskav/matförgiftning/hunger sänker fart, snabbar mätare och förlänger hjälptiden; många steg, lång tid och tom matmätare ger debuffar | +| `internal/sim` | `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow` | Egna kort: spärren kräver allas saldo, automaten frågar vems kort, spärren drar från alla; **hela bana 003 headless** med biljetter åt alla, sittplatser, trängsel och resa | +| `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -123,9 +128,10 @@ att bygga på Linux som på Windows. | ~~M4 Lämmeltåget~~ ✓ | Mätare, händelsefrekvens efter gruppstorlek, seedade händelser – finns, se tabellen ovan | | ~~M5 Minikarta~~ ✓ | Upptäckta rutor, nollställning per bana, avgångar från start – finns, se tabellen ovan | | ~~M6 Kort/biljetter/minispel~~ ✓ | Saldo, automat, biljetter, omskanning, batteri – finns, se tabellen ovan | -| M7 Svårighet/poäng/debuffs | Poängformeln (tid, pengar, mätare, läge); tidsgräns och maxantal missade tåg; varje debuff ändrar rätt stat; temporära debuffar räknar ned 1–4 banor | +| ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan | | M7b Tåg som kommer och går | `TrainPhase(t)` ger approaching/arriving/dwelling/departing/away med rätt fas ur tidtabellen; indikatorn aktiv bara inom N rutor från rälsen; vindzonen = rutor längs perrongkanten | | M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | +| M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | | M11 Oändligt läge & NG+ | Målsekvens deterministisk per seed; NG+ behåller skills men nollställer items | diff --git a/doc/plan.md b/doc/plan.md index e26e443..2d3b68b 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -34,8 +34,8 @@ brasse-linux01-runnern, inte Pi5:n. M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 -svårighet/poäng/debuffar → M7b tåg som kommer och går (vind, indikator, -animation) → M8 progression/sparfiler → M9 navigeringslägen → +svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går (vind, indikator, +animation) → M8 progression/sparfiler → M9a flera våningar → M9 navigeringslägen → M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). diff --git a/doc/roadmap.md b/doc/roadmap.md index 0ca330d..9146dc9 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -287,15 +287,46 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. spelbart; `TestTicketLevelFlow` spelar hela 002 headless: fel biljett stoppas, rätt biljett, spärr, trängsel, resa, vinst. ✓ -### M7 – Svårighet, tid, poäng och debuffar +### M7 – Svårighet, tid, poäng och debuffar ✓ (klar 2026-10-24) - **Mål:** banor som kan misslyckas och poäng att jaga. -- Tidsgräns och maxantal missade tåg på svåra banor; lätta banor obegränsade. -- Poängformel: tid, pengar/påfyllningar, mätarnas status, navigeringsläge. -- Stats (speed, törst, fokus, toalett, tur) och debuffar (onda fötter, - trötthet, hunger/irritation, matförgiftning); temporära debuffar 1–4 banor. -- Svårighetsprofil per bana i JSON. -- **Teknik:** `internal/score`, `internal/sim/stats`. +- Tidsgräns och maxantal missade tåg på svåra banor; lätta banor + obegränsade. +- Poängformel: tid mot par, pengar spenderade, sällskapets mätare vid + målgång, utforskat, avdrag för missade tåg och omskanningar; + navigeringsläge ger multiplikator i M9. +- Stats (speed, törst, fokus, toalett, tur) som multiplikatorer och + **debuffar** som påverkar dem: onda fötter (många steg), trötthet (lång + bana), hunger/irritation (tom matmätare), matförgiftning (seedad chans + vid kioskköp, snabbare toamätare). De temporära debuffarna från + sidouppdrag (träningsvärk, skoskav, tungt köp, proppmätt, svettig) finns + i systemet med varaktighet 1–4 banor; sidouppdragen som ger dem kommer i + M10. +- **Svårighetsinställningar per bana** (Björns tillägg 2026-10-24): + - **Egna kort** (`individualCards`): varje person i sällskapet har sitt + eget tågkort; spärren kräver att alla har pengar, biljettbanor att alla + har biljett. Påfyllningsautomaten frågar vems kort. + - **Sittplatsval** (`seatSelection`, kräver biljettbana): efter resmålet + väljer man platser i vagnen (fönster/gång, vänster/höger, bredvid + någon) efter sällskapets seedade önskemål. Missnöjda i sällskapet blir + sura: mätaravdrag och poängavdrag. + - Våningar (`floors`) är också en svårighetsvariabel men byggs i M9a. +- **Teknik:** `sim/stats.go` (Stats, Debuff, Profile – ren Go), + `Run.Score()`, `level` får `maxMissedTrains`, `individualCards`, + `seatSelection`; `minigame.TopUp` får personval, `minigame.Ticket` får + sittplatssteg. +- **Poängformeln som byggdes:** tid 500 × min(1, par/tid) + pengar + (300 − spenderat/10) + mätare (200 × sällskapets medel) + utforskat + (100 × andel) − avdrag (50/missat tåg, 10/omskanning, 30/missnöjd). +- **Bana 003 Shibuya** (svårighet 3, fyra i sällskapet): biljettbana med + egna kort, sittplatsval, fullt tåg, fem stopp, 6 min tidsgräns, max ett + missat tåg. Bana 002 fick tidsgräns, maxantal missade tåg och + sittplatsval. Generatorn är nu spec-driven (JSON) så fler banor går fort. +- **Klar när:** poängformeln, tidsgränsen, debuffarna och de två + inställningarna är testade (`TestScoreBreakdown`, + `TestTimeLimitAndMissedTrainsLose`, `TestDebuffsAffectRunAndAreEarned`, + `TestIndividualCardsAtGateAndMachine`, `TestLevel003Flow`, + `TestSeatSelectionPreferences`); bana 003 använder dem. ✓ ### M7b – Tåg som kommer och går (Björns tillägg 2026-10-24) @@ -329,6 +360,23 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att räkna ut från datan. +### M9a – Flera våningar (Björns tillägg 2026-10-24) + +- **Mål:** stationer i flera plan – ännu en svårighetsvariabel. +- Banformatet får **flera rutnät** (`floors`), ett per våning, med + **trappor** (och rulltrappor/hissar) som kopplar en ruta på en våning + till en ruta på en annan. Start kan ligga på en trappa upp eller ner: + under mark, ovan mark eller marknivå. +- Kamera, dimma, minikarta (en flik per våning eller stapel) och BFS-vägar + (`level.Path`, lösbarhetstestet, botarna) blir våningsmedvetna; skyltar + kan peka "↑ 2F / ↓ B1". +- **Teknik:** `level.Level.Floors []Grid` med `Stairs []Link`, `Point` + får `Z`; `sim.Mover` byter våning vid trappa; `tools/genart` får + trappruta; generatorn placerar trappor som flaskhalsar. +- **Klar när:** en bana med två våningar går att klara av boten headless, + minikartan visar rätt våning, och alla gamla banor fungerar oförändrat + (en våning). + ### M9 – Navigeringslägen: lista och skyltläge - **Mål:** de två sätten att spela story. @@ -341,7 +389,9 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. ### M10 – Story-innehåll och flerbytesbanor - **Mål:** hela resan från flygplats till flygplats. -- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss. +- Alla huvudbanor och sidobanor; banor med flera stopp och byten; buss; + stationer i flera våningar (M9a) och svårighetsinställningarna från M7 + i stigande kombinationer. - Slutanimation (karaktärerna flyger iväg, items och poäng visas) och eftertexter. - Överstimulerande UI/banor som extra svårighet. diff --git a/doc/spelbeskrivning.md b/doc/spelbeskrivning.md index 9aab742..d668b09 100644 --- a/doc/spelbeskrivning.md +++ b/doc/spelbeskrivning.md @@ -78,6 +78,14 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). - **Tåg och bussar har animationer** när de kör in till och ut från sina stationer och hållplatser, i stället för att bara dyka upp och försvinna. +**Flera våningar** (tillägg 2026-10-24): + +- Banor och stationer kan ha **flera våningar**. Trappor (och rulltrappor/ + hissar) tar dig upp och ner mellan dem – ännu ett sätt att justera + svårigheten på en bana. +- En bana kan **börja med en trappa** upp eller ner, eftersom en station kan + ligga under mark, ovan mark eller i marknivå. + ## 5. Sällskapet – lämmeltåget - Dina polare följer efter dig i en rad – därav *lämmeltåget*. @@ -136,6 +144,11 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). påfyllning. - Telefonen har **batteri**. Dör det måste du köpa biljett i stället. Detta ska nästan bara kunna hända på **långa banor**. +- **Sittplatser** (tillägg 2026-10-24): som svårighet i biljettminispelet + kan sällskapet ha önskemål om platser – någon vill sitta **bredvid en + viss person**, någon vill sitta vid **gången eller fönstret**, någon på + **höger eller vänster sida** av tåget. Du måste placera alla så att det + stämmer. ## 9. Svårighet och tid @@ -145,7 +158,12 @@ otydligt står tolkningen i [§15](#15-tolkningar-av-originalet). - **Extra svårigheter:** överstimulerande UI och banor byggda för att förvirra spelaren räknas som egna, extra svårigheter. - Variabler som styr svårighet: antal i sällskapet, andel engelska på - skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar. + skyltar, kort-/biljettkrav, tidsgräns, antal byten, UI-störningar, antal + våningar, egna kort, sittplatsönskemål. +- **Egna kort** (tillägg 2026-10-24): som svårighetsinställning har varje + person i sällskapet **sitt eget tågkort**. Alla måste ha pengar på kortet + för att komma igenom spärren – och på biljettbanor måste alla ha en + biljett. ## 10. Poäng diff --git a/internal/game/game.go b/internal/game/game.go index e1a826f..9686e1e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -32,6 +32,7 @@ type Game struct { Assets *Assets Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor + Profile *sim.Profile // aktiva debuffar – följer med mellan banor fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) scene Scene next Scene @@ -49,6 +50,7 @@ type Options struct { Points int // språkpoäng från start (för tester/skärmdumpar) Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar) Phone bool // kortet i telefonen från start + Debuffs []string // debuffar från start (för tester/skärmdumpar) CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard } @@ -65,6 +67,10 @@ func New(opt Options) (*Game, error) { g.Know.Points = opt.Points g.fixedSeed = opt.Seed g.Wallet = sim.NewWallet() + g.Profile = &sim.Profile{} + for _, d := range opt.Debuffs { + g.Profile.Add(sim.DebuffKind(d), 1) + } g.Wallet.Phone = opt.Phone if opt.CardYen >= 0 { g.Wallet.Card = opt.CardYen @@ -75,7 +81,7 @@ func New(opt Options) (*Game, error) { } } if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID, g.Know, g.Wallet, g.seed()) + s, err := NewLevelScene(a, opt.LevelID, g, g.seed()) if err != nil { return nil, err } @@ -127,3 +133,12 @@ func (g *Game) seed() rng.Seed { } return rng.NewSeed() } + +// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya +// debuffar läggs på profilen och alla räknas ned en bana. +func (g *Game) levelEnded(run *sim.Run) { + for _, d := range run.NewDebuffs { + g.Profile.Add(d.Kind, d.LevelsLeft) + } + g.Profile.EndLevel() +} diff --git a/internal/game/input.go b/internal/game/input.go index d8e0a45..cc6d325 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -81,19 +81,21 @@ func wordsJustPressed() bool { return virtual.pressed["l"] || inpututil.IsKeyJustPressed(ebiten.KeyL) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight) } -// miniMove: -1/+1 vid just-tryck på pil/WASD (upp/vänster = -1, ner/höger = +1). -func miniMove() int { +// miniMoveXY: just-tryck på pil/WASD/d-pad som (x, y) i -1/0/+1. +func miniMoveXY() (x, y int) { switch { - case virtual.pressed["up"] || virtual.pressed["left"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || - inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyA) || - gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftLeft): - return -1 - case virtual.pressed["down"] || virtual.pressed["right"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || - inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyD) || - gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight): - return 1 + case virtual.pressed["left"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft) || inpututil.IsKeyJustPressed(ebiten.KeyA) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftLeft): + x = -1 + case virtual.pressed["right"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowRight) || inpututil.IsKeyJustPressed(ebiten.KeyD) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftRight): + x = 1 } - return 0 + switch { + case virtual.pressed["up"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowUp) || inpututil.IsKeyJustPressed(ebiten.KeyW) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftTop): + y = -1 + case virtual.pressed["down"] || inpututil.IsKeyJustPressed(ebiten.KeyArrowDown) || inpututil.IsKeyJustPressed(ebiten.KeyS) || gamepadJustPressed(ebiten.StandardGamepadButtonLeftBottom): + y = 1 + } + return x, y } // confirmHeld: Enter/mellanslag eller A hålls nere (sedelinmatning). diff --git a/internal/game/minigame_view.go b/internal/game/minigame_view.go index 0bd536a..2b7ce8b 100644 --- a/internal/game/minigame_view.go +++ b/internal/game/minigame_view.go @@ -45,6 +45,21 @@ func (s *LevelScene) drawTopUp(a *Assets, screen *ebiten.Image, m *minigame.TopU a.Draw(screen, a.T("mini.topup.title"), x+60, y+2, colText) y += 20 switch m.Stage { + case minigame.TopUpPickMember: + a.Draw(screen, a.T("mini.topup.member"), x, y, colDim) + y += LineH + 4 + for i, name := range m.Members { + col := colText + if i == m.Cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(y+i*LineH-1), 200, LineH, colBar, false) + col = colKnown + } + bal := s.run.Wallet.Card + if i > 0 { + bal = s.run.Party.Members[i].Card + } + a.Draw(screen, fmt.Sprintf("%-8s %5d¥", name, bal), x, y+i*LineH, col) + } case minigame.TopUpPickButton: a.Draw(screen, a.T("mini.topup.pick"), x, y, colDim) y += LineH + 4 @@ -85,6 +100,10 @@ func (s *LevelScene) drawTicket(a *Assets, screen *ebiten.Image, m *minigame.Tic a.DrawBig(screen, "きっぷ", x, y, colKnown) a.Draw(screen, a.T("mini.ticket.title"), x+48, y+2, colText) y += 20 + if m.Stage == minigame.TicketPickSeat { + s.drawSeats(a, screen, m, p, x, y) + return + } a.Draw(screen, fmt.Sprintf(a.T("mini.ticket.hint"), s.run.Level.Destination.Get("romaji"), m.Cash), x, y, colDim) y += LineH + 4 for i, o := range m.Options { @@ -154,3 +173,64 @@ func (s *LevelScene) drawRide(a *Assets, screen *ebiten.Image, m *minigame.Ride, } a.Draw(screen, hint, x, p.Max.Y-16, colGood) } + +// prefText beskriver ett önskemål. +func prefText(a *Assets, m *minigame.Ticket, p minigame.SeatPref) string { + switch p.Kind { + case minigame.PrefWindow: + return a.T("seat.window") + case minigame.PrefAisle: + return a.T("seat.aisle") + case minigame.PrefLeft: + return a.T("seat.left") + case minigame.PrefRight: + return a.T("seat.right") + default: + return fmt.Sprintf(a.T("seat.with"), m.Members[p.With]) + } +} + +// drawSeats ritar vagnen: tre rader, fönster V | gång | fönster H, och +// sällskapets önskemål till höger. +func (s *LevelScene) drawSeats(a *Assets, screen *ebiten.Image, m *minigame.Ticket, p image.Rectangle, x, y int) { + a.Draw(screen, fmt.Sprintf(a.T("mini.seat.hint"), m.Members[m.Current]), x, y, colDim) + y += LineH + 4 + const sw, sh, gap = 22, 18, 10 + a.Draw(screen, a.T("seat.front"), x+2, y-2, colDim) + y += 10 + for row := 0; row < minigame.SeatRows; row++ { + for col := 0; col < minigame.SeatCols; col++ { + sx := x + col*(sw+2) + if col >= 2 { + sx += gap + } + sy := y + row*(sh+3) + seat := minigame.Seat{Row: row, Col: col} + bg := colBar + if m.SeatTaken(seat) { + bg = color.RGBA{90, 105, 136, 255} + } + vector.DrawFilledRect(screen, float32(sx), float32(sy), sw, sh, bg, false) + if seat == m.Seat { + vector.DrawFilledRect(screen, float32(sx), float32(sy), sw, 2, colKnown, false) + vector.DrawFilledRect(screen, float32(sx), float32(sy+sh-2), sw, 2, colKnown, false) + } + for i, as := range m.Assigned { + if as == seat { + a.Draw(screen, string([]rune(m.Members[i])[:1]), sx+8, sy+3, colText) + } + } + } + } + // Önskemål. + px := x + 4*(sw+2) + gap + 14 + a.Draw(screen, a.T("mini.seat.prefs"), px, y-10, colDim) + for i, pr := range m.Prefs { + col := colText + if pr.Member == m.Current { + col = colKnown + } + a.Draw(screen, m.Members[pr.Member]+": "+prefText(a, m, pr), px, y+i*LineH, col) + } + a.Draw(screen, a.T("mini.cancel"), x, p.Max.Y-16, colDim) +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index 69c94eb..0e2b0cb 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -36,16 +36,16 @@ type LevelScene struct { hideMap bool // M döljer/visar minikartan } -// NewLevelScene laddar banan och ställer spelaren på startrutan. know är -// spelarens japanska, som delas mellan banor; seed styr omgångens slump. -func NewLevelScene(a *Assets, id string, know *lang.Knowledge, wallet *sim.Wallet, seed rng.Seed) (*LevelScene, error) { +// NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap, +// plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen. +func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) { lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ id: id, - run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: know, Wallet: wallet, Seed: seed}), + run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), } s.followPlayer() @@ -79,8 +79,10 @@ func (s *LevelScene) followPlayer() { func (s *LevelScene) Update(g *Game) error { if s.run.Mini != nil { // Minispel: all input går dit; Esc avbryter minispelet, inte banan. - s.run.StepMini(dt, minigame.Input{Move: miniMove(), Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) + mx, my := miniMoveXY() + s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) if s.run.Outcome != sim.Ongoing { + g.levelEnded(s.run) g.SetScene(NewResultScene(s)) } return nil @@ -107,11 +109,43 @@ func (s *LevelScene) Update(g *Game) error { } s.followPlayer() if s.run.Outcome != sim.Ongoing { + g.levelEnded(s.run) g.SetScene(NewResultScene(s)) } return nil } +// MiniPick flyttar markören i det pågående minispelet ett steg mot +// alternativet med texten text; true när det är valt (skriptläget). +func (s *LevelScene) MiniPick(text string) bool { + switch m := s.run.Mini.(type) { + case *minigame.Ticket: + if m.Stage == minigame.TicketPickDest { + if m.Options[m.Cursor].Dest == text { + return true + } + s.run.StepMini(0, minigame.Input{Move: 1}) + return false + } + case *minigame.TopUp: + switch m.Stage { + case minigame.TopUpPickMember: + if m.Members[m.Cursor] == text { + return true + } + s.run.StepMini(0, minigame.Input{Move: 1}) + return false + case minigame.TopUpPickButton: + if m.Buttons[m.Cursor] == text { + return true + } + s.run.StepMini(0, minigame.Input{MoveX: 1}) + return false + } + } + return true +} + // trainTile är rutan "bortom perrongkanten" där tågvagnen ritas: första // väggruta nedanför, till höger, till vänster eller ovanför perrongen. func (s *LevelScene) trainTile(p level.Point) level.Point { @@ -243,14 +277,26 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { a.Draw(screen, a.T("hud.destination")+": "+dest, destX, 0, colKnown) a.Draw(screen, fmt.Sprintf("%s Lv%d·%d", a.T("hud.japanese"), r.Know.Level(), r.Know.Count()), destX+a.Width(a.T("hud.destination")+": "+dest)+12, 0, colDim) - wallet := fmt.Sprintf("%s %3.0fs %s %d¥ %s %d¥", a.T("hud.time"), r.Time, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash"), r.Wallet.Cash) + timeStr := fmt.Sprintf("%s %3.0fs", a.T("hud.time"), r.Time) + if r.Level.TimeLimitSec > 0 { + left := r.TimeLeft() + timeStr = fmt.Sprintf("%s %d:%02d", a.T("hud.time_left"), int(left)/60, int(left)%60) + } + wallet := fmt.Sprintf("%s %s %d¥ %s %d¥", timeStr, a.T("hud.card"), r.Wallet.Card, a.T("hud.cash_short"), r.Wallet.Cash) + if r.Level.MaxMissedTrains > 0 { + wallet += fmt.Sprintf(" %s %d/%d", a.T("hud.missed_short"), r.MissedTrains, r.Level.MaxMissedTrains) + } if r.Wallet.Phone { wallet += fmt.Sprintf(" %s %.0f%%", a.T("hud.phone"), r.Wallet.Battery) } if r.Wallet.Ticket != "" { wallet += " " + a.T("hud.ticket") + " " + r.Wallet.Ticket } - a.Draw(screen, wallet, 4, LineH, colDim) + walletCol := colDim + if r.Level.TimeLimitSec > 0 && r.TimeLeft() < 30 { + walletCol = colBad + } + a.Draw(screen, wallet, 4, LineH, walletCol) s.drawMeters(g, screen) if r.Outcome != sim.Ongoing || r.Mini != nil { @@ -278,6 +324,13 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { } lines = append(lines, a.wrapLine(plainLine(a.T(r.FlashKey), col), ScreenW-8)...) } + if debuffs := g.Profile.Debuffs; len(debuffs) > 0 && r.Time < 6 { + var parts []string + for _, d := range debuffs { + parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) + } + lines = append(lines, a.wrapLine(plainLine(a.T("hud.debuffs")+": "+strings.Join(parts, ", "), colBad), ScreenW-8)...) + } // Inforutans läge: nederkant, men flyttas upp under HUD:en om spelaren // (eller tåget under hen) annars skulle hamna bakom den. @@ -390,10 +443,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec { key := "hud.gate_denied" switch { - case lvl.TicketRequired: + case lvl.TicketRequired && r.Wallet.Ticket == "": key = sim.FlashNoTicket + case lvl.TicketRequired: + key = "hud.need_all_tickets" case r.Wallet.Phone && r.Wallet.Battery <= 0: key = "hud.phone_dead" + case len(r.MembersWithoutFare()) > 0: + var names []string + for _, m := range r.MembersWithoutFare() { + names = append(names, m.Name) + } + return []line{plainLine(fmt.Sprintf(a.T(sim.FlashNoCards), strings.Join(names, ", ")), colBad)}, colBad, false } return []line{plainLine(a.T(key), colBad)}, colBad, false } @@ -403,11 +464,11 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar // drawMeters ritar sällskapets mat/dryck/toa-mätare uppe till höger. func (s *LevelScene) drawMeters(g *Game, screen *ebiten.Image) { members := s.run.Party.Members - const w, gap = 14, 4 + const w, gap = 11, 3 x0 := ScreenW - 4 - len(members)*(w+gap) for i, m := range members { x := float32(x0 + i*(w+gap)) - g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+3, 0, colDim) + g.Assets.Draw(screen, string([]rune(m.Name)[:1]), int(x)+2, 0, colDim) for j, v := range []struct { val float64 col color.Color diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go index 2ccc106..cd1ea64 100644 --- a/internal/game/scene_menu.go +++ b/internal/game/scene_menu.go @@ -64,7 +64,7 @@ func (m *MenuScene) Update(g *Game) error { if n := len(m.entries); n > 0 { m.sel = (m.sel + menuMove() + n) % n if confirmJustPressed() { - s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g.Know, g.Wallet, g.seed()) + s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g, g.seed()) if err != nil { m.err = err return nil @@ -100,6 +100,13 @@ func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { } a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d %s %d¥ %s %d¥ %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"), a.T("words.points"), g.Know.Points, a.T("hud.card"), g.Wallet.Card, a.T("hud.cash"), g.Wallet.Cash, fmt.Sprintf(a.T("menu.phone"), onoff)), 8, ScreenH-38, colDim) + 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, ScreenH-50, colBad) + } a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim) a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) } diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index e170d92..09a3e9a 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -23,7 +23,7 @@ func (r *ResultScene) Update(g *Game) error { case wordsJustPressed(): g.SetScene(NewWordsScene(g.Assets, r)) case inpututil_R(): - s, err := NewLevelScene(g.Assets, r.from.id, g.Know, g.Wallet, g.seed()) + s, err := NewLevelScene(g.Assets, r.from.id, g, g.seed()) if err != nil { return err } @@ -42,26 +42,28 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run a := g.Assets - vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 120, colPanel, false) + vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 138, colPanel, false) accent, headline := colBad, a.T("level.lost") if run.Outcome == sim.Won { accent, headline = colGood, a.T("level.cleared") } - vector.DrawFilledRect(screen, 20, 36, ScreenW-40, 3, accent, false) - a.DrawBig(screen, headline, 28, 44, accent) + vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 3, accent, false) + a.DrawBig(screen, headline, 28, 36, accent) if run.Outcome == sim.Won { stars := strings.Repeat("★", run.Stars) + strings.Repeat("☆", 3-run.Stars) - a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 44, colWarn) + a.DrawBig(screen, stars, ScreenW-28-a.Width(stars)-8, 36, colWarn) } - y := 62 + y := 54 + if run.Boarded == nil && run.Outcome == sim.Lost { + a.Draw(screen, a.T(run.LostReason), 28, y, colText) + y += LineH + } if tr := run.Boarded; tr != nil { a.Draw(screen, fmt.Sprintf("%s%s %s %s%s", tr.Track, a.T("hud.track_suffix_ja"), tr.Line.Get("ja"), tr.Destination.Get("ja"), a.T("hud.for_ja")), 28, y, colKnown) y += LineH - a.Draw(screen, fmt.Sprintf("%s %s %s → %s", a.T("hud.track"), tr.Track, tr.Line.Get("romaji"), tr.Destination.Get("romaji")), 28, y, colDim) - y += LineH if run.Outcome == sim.Lost { reason := a.T(run.LostReason) if run.LostReason == sim.LostWrongTrain { @@ -77,18 +79,32 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, fmt.Sprintf("%s %.1fs (%s %.0fs) %s %d %s %.0f %%", a.T("hud.time"), run.Time, a.T("result.par"), run.ParSec(), a.T("hud.steps"), run.Steps, a.T("result.explored"), 100*run.Fog.Explored(run.Level)), 28, y, colText) y += LineH - a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d¥ %s %d %s %d", a.T("result.gates"), run.GatePasses, a.T("result.card"), run.Wallet.Card, - a.T("result.spent"), run.Wallet.Spent, a.T("result.missed"), run.MissedTrains, a.T("result.served"), run.Served), 28, y, colText) + a.Draw(screen, fmt.Sprintf("%s %d %s %d¥ %s %d¥ %s %d", a.T("result.gates"), run.GatePasses, a.T("hud.card"), run.Wallet.Card, + a.T("result.spent"), run.Wallet.Spent, a.T("result.missed_short"), run.MissedTrains), 28, y, colText) y += LineH if run.Outcome == sim.Won { - msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("words.points")) + sc := run.Score + a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText) + y += LineH + a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money, + a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.penalty"), sc.Penalty), 28, y, colDim) + y += LineH + msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points")) if len(run.LearnedDest) > 0 { msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")" } a.Draw(screen, msg, 28, y, colKnown) y += LineH } + if len(run.NewDebuffs) > 0 { + var parts []string + for _, d := range run.NewDebuffs { + parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) + } + a.Draw(screen, a.T("result.new_debuffs")+": "+strings.Join(parts, ", "), 28, y, colBad) + y += LineH + } 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, 141, colDim) + a.Draw(screen, a.T("result.hint"), 28, 155, colDim) } diff --git a/internal/game/script.go b/internal/game/script.go index 864b551..832d413 100644 --- a/internal/game/script.go +++ b/internal/game/script.go @@ -22,6 +22,7 @@ import ( // hold N håll Enter nere N bildrutor (sedelinmatning) // waittrain vänta tills tåget vid perrongen spelaren står på är inne // waitstop vänta tills tågresan står vid resmålet +// pick TEXT flytta markören i minispelet tills alternativet TEXT är valt // press KEY tryck enter | esc | r | h | l | up | down en bildruta // move DIR N gå N rutor åt up | down | left | right (stannar exakt där) // shot PATH spara aktuell bild som PNG (intern upplösning × shotScale) @@ -86,9 +87,9 @@ func parseScript(src string) (*script, error) { return nil, fmt.Errorf("script: okänd riktning %q", f[1]) } st.n, _ = strconv.Atoi(f[2]) - case "shot": + case "shot", "pick": if len(f) != 2 { - return nil, fmt.Errorf("script: 'shot PATH'") + return nil, fmt.Errorf("script: '%s TEXT'", f[0]) } st.arg = f[1] case "quit", "waittrain", "waitstop": @@ -124,6 +125,7 @@ type playerTiler interface { type trainWaiter interface { TrainHere() bool RideAtDest() bool + MiniPick(text string) bool } // tick körs i början av varje Update. frame är senast ritade bild. @@ -191,6 +193,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error { if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() { s.advance() } + case "pick": + s.counter++ + if tw, ok := g.scene.(trainWaiter); !ok || tw.MiniPick(st.arg) || s.counter > 60 { + s.advance() + } case "shot": if err := saveShot(frame, st.arg); err != nil { return err diff --git a/internal/level/level.go b/internal/level/level.go index 40bd318..f062110 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -108,19 +108,22 @@ type SignText struct { // Level är en bana. type Level struct { - ID string `json:"id"` - Name Text `json:"name"` - Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) - Destination Text `json:"destination"` // dit spelaren ska - TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns - ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) - Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) - TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort - RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops) - FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) - Grid []string `json:"grid"` - Trains []Train `json:"trains"` - Signs []SignText `json:"signs"` + ID string `json:"id"` + Name Text `json:"name"` + Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) + Destination Text `json:"destination"` // dit spelaren ska + TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns + ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen) + Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1) + TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort + RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops) + MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan + IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver + SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål + FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard) + Grid []string `json:"grid"` + Trains []Train `json:"trains"` + Signs []SignText `json:"signs"` } // Point är en rutkoordinat. @@ -253,6 +256,12 @@ func (l *Level) Validate() error { if l.TicketRequired && len(l.Find(TicketMachine)) == 0 { return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID) } + if l.SeatSelection && !l.TicketRequired { + return fmt.Errorf("level %s: sittplatsval kräver biljettbana", l.ID) + } + if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 { + return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID) + } if l.Party > MaxParty { return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty) } diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 50a63fd..398c5d6 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -306,3 +306,18 @@ func TestTicketAndRideFields(t *testing.T) { t.Fatal("fälten ska läsas") } } + +func TestDifficultySettings(t *testing.T) { + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "seatSelection": true`, 1))); err == nil { + t.Fatal("sittplatsval utan biljettbana ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "maxMissedTrains": -1`, 1))); err == nil { + t.Fatal("negativt maxantal ska ge fel") + } + src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`, + `"difficulty": 3, "ticketRequired": true, "seatSelection": true, "individualCards": true, "maxMissedTrains": 2, "timeLimitSec": 300`, 1) + l := mustParse(t, src) + if !l.SeatSelection || !l.IndividualCards || l.MaxMissedTrains != 2 || l.TimeLimitSec != 300 { + t.Fatal("inställningarna ska läsas") + } +} diff --git a/internal/minigame/minigame.go b/internal/minigame/minigame.go index a8d5ca5..2d65b44 100644 --- a/internal/minigame/minigame.go +++ b/internal/minigame/minigame.go @@ -12,7 +12,8 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" // Input är vad spelaren gjorde den här bildrutan. type Input struct { - Move int // -1 / +1 (upp/vänster resp. ner/höger), 0 = inget + Move int // -1 / +1 (upp resp. ner), 0 = inget + MoveX int // -1 / +1 (vänster resp. höger), 0 = inget Confirm bool // Enter tryckt (just nu) Hold bool // Enter hålls nere Cancel bool // Esc tryckt diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go index 5cc41fe..e167d5c 100644 --- a/internal/minigame/minigame_test.go +++ b/internal/minigame/minigame_test.go @@ -11,13 +11,13 @@ import ( const frame = 1.0 / 60 func TestTopUpFlowAndDeterminism(t *testing.T) { - g := NewTopUp(rng.FromInt(1), 5000) - h := NewTopUp(rng.FromInt(1), 5000) + g := NewTopUp(rng.FromInt(1), 5000, nil) + h := NewTopUp(rng.FromInt(1), 5000, nil) if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") { t.Fatal("samma seed ska ge samma knappordning") } - if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000).Buttons, ",") && - strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000).Buttons, ",") { + if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000, nil).Buttons, ",") && + strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000, nil).Buttons, ",") { t.Fatal("olika seeds ska (nästan alltid) ge olika ordning") } // Fel knapp: straff och frysning. @@ -37,7 +37,7 @@ func TestTopUpFlowAndDeterminism(t *testing.T) { } // Rätt knapp -> belopp -> mata sedlar. for g.Buttons[g.Cursor] != "チャージ" { - g.Update(Input{Move: 1}, frame) + g.Update(Input{MoveX: 1}, frame) } g.Update(Input{Confirm: true}, frame) if g.Stage != TopUpPickAmount { @@ -65,14 +65,14 @@ func TestTopUpFlowAndDeterminism(t *testing.T) { t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount) } // Kontanter begränsar beloppen; utan pengar är spelet över direkt. - if a := NewTopUp(rng.FromInt(1), 2500).Amounts; len(a) != 2 || a[1] != 2000 { + if a := NewTopUp(rng.FromInt(1), 2500, nil).Amounts; len(a) != 2 || a[1] != 2000 { t.Fatalf("amounts med 2500 kr: %v", a) } - if !NewTopUp(rng.FromInt(1), 500).Done() { + if !NewTopUp(rng.FromInt(1), 500, nil).Done() { t.Fatal("utan råd ska automaten vara klar direkt") } // Esc avbryter utan köp. - c := NewTopUp(rng.FromInt(1), 5000) + c := NewTopUp(rng.FromInt(1), 5000, nil) c.Update(Input{Cancel: true}, frame) if !c.Done() || c.Success() { t.Fatal("avbrutet = klart utan framgång") @@ -80,8 +80,8 @@ func TestTopUpFlowAndDeterminism(t *testing.T) { } func TestTicketBoardSeededAndAffordable(t *testing.T) { - g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) - h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000) + g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil) + h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil) if len(g.Options) != 4 { t.Fatalf("%d alternativ", len(g.Options)) } @@ -102,7 +102,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) { t.Fatalf("köp: %+v", g.Chosen) } // Utan råd går det inte att köpa. - p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100) + p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100, nil) p.Update(Input{Confirm: true}, frame) if p.Done() { t.Fatal("utan råd ska köpet inte gå igenom") @@ -204,3 +204,92 @@ func TestRideWithoutFillersStillHasDestination(t *testing.T) { t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest) } } + +func TestTopUpPicksMemberWithIndividualCards(t *testing.T) { + g := NewTopUp(rng.FromInt(1), 5000, []string{"Du", "Anna", "Ville"}) + if g.Stage != TopUpPickMember { + t.Fatal("med egna kort väljs person först") + } + g.Update(Input{Move: 1}, frame) + g.Update(Input{Confirm: true}, frame) + if g.Member != 1 || g.Stage != TopUpPickButton || g.Cursor != 0 { + t.Fatalf("member=%d stage=%v", g.Member, g.Stage) + } + // 2×2-rutnätet: höger = +1, ner = +2. + g.Update(Input{MoveX: 1}, frame) + g.Update(Input{Move: 1}, frame) + if g.Cursor != 3 { + t.Fatalf("cursor=%d", g.Cursor) + } + if NewTopUp(rng.FromInt(1), 5000, []string{"Du"}).Stage != TopUpPickButton { + t.Fatal("ensam = inget personval") + } +} + +func TestSeatSelectionPreferences(t *testing.T) { + members := []string{"Du", "Anna", "Ville", "Sara"} + g := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members) + h := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members) + if len(g.Prefs) != 3 || len(g.Assigned) != 4 { + t.Fatalf("prefs=%d assigned=%d", len(g.Prefs), len(g.Assigned)) + } + for i := range g.Prefs { + if g.Prefs[i] != h.Prefs[i] { + t.Fatal("önskemålen ska vara seedade") + } + if g.Prefs[i].Kind == PrefWith && g.Prefs[i].With == g.Prefs[i].Member { + t.Fatal("ingen vill sitta bredvid sig själv") + } + } + // Köp resmålet -> sittplatssteget. + for g.Options[g.Cursor].Dest != "渋谷" { + g.Update(Input{Move: 1}, frame) + } + g.Update(Input{Confirm: true}, frame) + if g.Done() || g.Stage != TicketPickSeat || g.Chosen == nil { + t.Fatal("efter resmålet kommer sittplatserna") + } + // Placera alla på rad 0: plats 0,1,2,3 i ordning; upptagen plats går inte. + g.Update(Input{Confirm: true}, frame) // Du på (0,0) + g.Update(Input{Confirm: true}, frame) // upptagen: ignoreras + if g.Current != 1 { + t.Fatal("upptagen plats ska inte gå att välja") + } + for i := 1; i < 4; i++ { + g.Update(Input{MoveX: 1}, frame) + g.Update(Input{Confirm: true}, frame) + } + if !g.Done() || !g.Success() { + t.Fatal("alla placerade = köpet klart") + } + // Facit: räkna ut vilka som borde vara missnöjda med placeringen rad 0 / plats i. + var want []int + for _, p := range g.Prefs { + if !p.Satisfied(g.Assigned) { + want = append(want, p.Member) + } + } + if len(g.Unhappy) != len(want) { + t.Fatalf("unhappy=%v want=%v (prefs %+v)", g.Unhappy, want, g.Prefs) + } + // Egenskaperna. + if !(Seat{0, 0}).Window() || !(Seat{0, 3}).Window() || (Seat{0, 1}).Window() || !(Seat{0, 1}).Aisle() { + t.Fatal("fönster/gång") + } + if !(Seat{0, 0}).Left() || !(Seat{0, 2}).Right() || !(Seat{1, 0}).Beside(Seat{1, 1}) || (Seat{1, 1}).Beside(Seat{1, 2}) || (Seat{0, 0}).Beside(Seat{1, 0}) { + t.Fatal("sida/bredvid") + } + if (SeatPref{Member: 1, Kind: PrefWindow}).Satisfied([]Seat{{0, 0}, {-1, 0}}) { + t.Fatal("oplacerad = inte nöjd") + } + // Esc under sittplatsvalet avbryter köpet. + c := NewTicket(rng.FromInt(9), "渋谷", []string{"東京"}, 5000, members) + for c.Options[c.Cursor].Dest != "渋谷" { + c.Update(Input{Move: 1}, frame) + } + c.Update(Input{Confirm: true}, frame) + c.Update(Input{Cancel: true}, frame) + if !c.Done() || c.Success() { + t.Fatal("avbrutet sittplatsval = inget köp") + } +} diff --git a/internal/minigame/ticket.go b/internal/minigame/ticket.go index 39e1e91..801e39c 100644 --- a/internal/minigame/ticket.go +++ b/internal/minigame/ticket.go @@ -4,13 +4,91 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" // Ticket är biljettautomaten: en taxetavla med resmål (på japanska) i seedad // ordning. Spelaren väljer ett resmål och betalar – om det var rätt avgör -// spärren/tåget senare. +// tåget senare. Med sittplatsval följer ett steg där sällskapet placeras i +// vagnen efter sina seedade önskemål. type Ticket struct { base Options []TicketOption Cursor int Chosen *TicketOption Cash int + + // Sittplatser (nil Members = inget sittplatssteg). + Stage TicketStage + Members []string + Prefs []SeatPref // önskemål för medlemmar 1..n-1 + Assigned []Seat // vald plats per medlem, Row -1 = ingen än + Current int // medlemmen som placeras just nu + Seat Seat // markören i vagnen + Unhappy []int // medlemmar vars önskemål inte uppfylldes (efter köpet) +} + +// TicketStage är var i köpet man är. +type TicketStage int + +// Steg. +const ( + TicketPickDest TicketStage = iota + TicketPickSeat +) + +// Vagnen: SeatRows rader med fyra platser – fönster V, gång V | gång H, fönster H. +const ( + SeatRows = 3 + SeatCols = 4 +) + +// Seat är en plats i vagnen. +type Seat struct{ Row, Col int } + +// Window/Aisle/Left/Right-egenskaper för en plats. +func (s Seat) Window() bool { return s.Col == 0 || s.Col == 3 } +func (s Seat) Aisle() bool { return s.Col == 1 || s.Col == 2 } +func (s Seat) Left() bool { return s.Col <= 1 } +func (s Seat) Right() bool { return s.Col >= 2 } + +// Beside: samma rad och samma platspar (0–1 eller 2–3), inte över gången. +func (s Seat) Beside(o Seat) bool { return s.Row == o.Row && s.Col/2 == o.Col/2 && s.Col != o.Col } + +// PrefKind är ett slags önskemål. +type PrefKind int + +// Önskemål. +const ( + PrefWindow PrefKind = iota + PrefAisle + PrefLeft + PrefRight + PrefWith // bredvid en viss person +) + +// SeatPref är en medlems önskemål. +type SeatPref struct { + Member int + Kind PrefKind + With int // medlemsindex vid PrefWith +} + +// Satisfied kontrollerar önskemålet mot de valda platserna. +func (p SeatPref) Satisfied(assigned []Seat) bool { + s := assigned[p.Member] + if s.Row < 0 { + return false + } + switch p.Kind { + case PrefWindow: + return s.Window() + case PrefAisle: + return s.Aisle() + case PrefLeft: + return s.Left() + case PrefRight: + return s.Right() + case PrefWith: + o := assigned[p.With] + return o.Row >= 0 && s.Beside(o) + } + return true } // TicketOption är ett resmål på tavlan. @@ -20,8 +98,9 @@ type TicketOption struct { } // NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade. -// Taxan är seedad per resmål (140–620 yen i steg om 10). -func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket { +// Taxan är seedad per resmål (140–620 yen i steg om 10). members (≥2) +// aktiverar sittplatsvalet med seedade önskemål för alla utom spelaren. +func NewTicket(seed rng.Seed, correct string, others []string, cash int, members []string) *Ticket { r := newRand(seed, "ticket") g := &Ticket{Cash: cash} names := append([]string{correct}, others...) @@ -29,25 +108,74 @@ func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket g.Options = append(g.Options, TicketOption{Dest: n, Fare: 140 + 10*r.intn(49)}) } r.shuffle(len(g.Options), func(i, j int) { g.Options[i], g.Options[j] = g.Options[j], g.Options[i] }) + if len(members) >= 2 && len(members) <= SeatRows*SeatCols { + g.Members = append([]string(nil), members...) + for i := 1; i < len(members); i++ { + p := SeatPref{Member: i, Kind: PrefKind(r.intn(5))} + if p.Kind == PrefWith { + p.With = r.intn(len(members) - 1) + if p.With >= i { + p.With++ // aldrig sig själv + } + } + g.Prefs = append(g.Prefs, p) + } + g.Assigned = make([]Seat, len(members)) + for i := range g.Assigned { + g.Assigned[i] = Seat{Row: -1} + } + } return g } -// Update flyttar markören och köper. +// SeatTaken rapporterar om platsen redan är vald. +func (g *Ticket) SeatTaken(s Seat) bool { + for _, a := range g.Assigned { + if a == s { + return true + } + } + return false +} + +// Update flyttar markören, köper och placerar sällskapet. func (g *Ticket) Update(in Input, dt float64) { if g.done { return } if in.Cancel { - g.done = true + g.done = true // avbrutet: inget köp return } - g.Cursor = wrap(g.Cursor+in.Move, len(g.Options)) - if in.Confirm { - opt := g.Options[g.Cursor] - if opt.Fare > g.Cash { - return // har inte råd: välj annat eller avbryt + switch g.Stage { + case TicketPickDest: + g.Cursor = wrap(g.Cursor+in.Move, len(g.Options)) + if in.Confirm { + opt := g.Options[g.Cursor] + if opt.Fare > g.Cash { + return // har inte råd: välj annat eller avbryt + } + g.Chosen = &opt + if g.Members == nil { + g.done, g.success = true, true + return + } + g.Stage = TicketPickSeat + } + case TicketPickSeat: + g.Seat.Row = wrap(g.Seat.Row+in.Move, SeatRows) + g.Seat.Col = wrap(g.Seat.Col+in.MoveX, SeatCols) + if in.Confirm && !g.SeatTaken(g.Seat) { + g.Assigned[g.Current] = g.Seat + g.Current++ + if g.Current >= len(g.Members) { + for _, p := range g.Prefs { + if !p.Satisfied(g.Assigned) { + g.Unhappy = append(g.Unhappy, p.Member) + } + } + g.done, g.success = true, true + } } - g.Chosen = &opt - g.done, g.success = true, true } } diff --git a/internal/minigame/topup.go b/internal/minigame/topup.go index 1d8943e..92ad532 100644 --- a/internal/minigame/topup.go +++ b/internal/minigame/topup.go @@ -2,11 +2,13 @@ package minigame import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" -// TopUp är påfyllningsautomaten: hitta チャージ-knappen bland japanska -// knappar i seedad ordning, välj belopp, mata in sedlar genom att hålla -// Enter. Fel knapp kostar en straffsekund eller två. +// TopUp är påfyllningsautomaten: (vems kort, om alla har egna), hitta +// チャージ-knappen bland japanska knappar i seedad ordning, välj belopp, mata +// in sedlar genom att hålla Enter. Fel knapp kostar en straffsekund eller två. type TopUp struct { base + Members []string // namn när alla har egna kort (annars tom) + Member int // vald person (index i Members, 0 = spelaren) Stage TopUpStage Buttons []string // knapparnas text, seedad ordning Cursor int @@ -26,6 +28,7 @@ const ( TopUpPickButton TopUpStage = iota TopUpPickAmount TopUpFeed + TopUpPickMember // först, bara med egna kort ) // Knapparna på en japansk biljettautomat. チャージ är den rätta. @@ -40,10 +43,15 @@ const ( topUpDrainPerSec = 1.2 ) -// NewTopUp skapar automaten. maxCash begränsar beloppen. -func NewTopUp(seed rng.Seed, maxCash int) *TopUp { +// NewTopUp skapar automaten. maxCash begränsar beloppen. members anges +// när alla i sällskapet har egna kort; då väljs person först. +func NewTopUp(seed rng.Seed, maxCash int, members []string) *TopUp { r := newRand(seed, "topup") g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash} + if len(members) > 1 { + g.Members = append([]string(nil), members...) + g.Stage = TopUpPickMember + } r.shuffle(len(g.Buttons), func(i, j int) { g.Buttons[i], g.Buttons[j] = g.Buttons[j], g.Buttons[i] }) for _, a := range TopUpAmounts { if a <= maxCash { @@ -70,8 +78,14 @@ func (g *TopUp) Update(in Input, dt float64) { return } switch g.Stage { + case TopUpPickMember: + g.Cursor = wrap(g.Cursor+in.Move, len(g.Members)) + if in.Confirm { + g.Member = g.Cursor + g.Stage, g.Cursor = TopUpPickButton, 0 + } case TopUpPickButton: - g.Cursor = wrap(g.Cursor+in.Move, len(g.Buttons)) + g.Cursor = wrap(g.Cursor+2*in.Move+in.MoveX, len(g.Buttons)) // 2×2-rutnät if in.Confirm { if g.Buttons[g.Cursor] == topUpButtons[0] { g.Stage, g.Cursor = TopUpPickAmount, 0 diff --git a/internal/sim/party.go b/internal/sim/party.go index 282deff..11eb96b 100644 --- a/internal/sim/party.go +++ b/internal/sim/party.go @@ -48,8 +48,16 @@ type Member struct { Meters Meters Urge Urge UrgeAt float64 // när behovet uppstod + Card int // eget tågkort (yen) när banan kräver egna kort; spelaren använder plånboken } +// MemberCardYen är vad följarnas egna kort har från start (lite – de får +// fyllas på). +const MemberCardYen = 500 + +// Rates skalar hur fort mätarna sjunker (debuffar/stats). +type Rates struct{ Thirst, Toilet float64 } + // PartyNames är namnen i den ordning de följer med. var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"} @@ -72,6 +80,7 @@ const ( // Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår. type Party struct { Members []*Member + Rates Rates trail []level.Point // ledarens rutor, senaste sist rand *rand.Rand nextEventAt float64 @@ -83,9 +92,9 @@ type Party struct { // sällskapet kommer från seed. func NewParty(n int, start level.Point, seed rng.Seed) *Party { n = max(1, min(n, len(PartyNames))) - p := &Party{rand: seed.New()} + p := &Party{rand: seed.New(), Rates: Rates{1, 1}} for i := 0; i < n; i++ { - p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}}) + p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen}) } p.trail = []level.Point{start} p.nextEventAt = p.interval() * (0.5 + p.rand.Float64()) @@ -106,8 +115,8 @@ func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) } func (p *Party) Advance(dt, t float64) { for _, m := range p.Members { m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt) - m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*dt) - m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*dt) + m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*p.Rates.Thirst*dt) + m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*p.Rates.Toilet*dt) if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec { m.Urge = NoUrge } @@ -185,6 +194,24 @@ func (p *Party) SpeedFactor() float64 { return f } +// Names är medlemmarnas namn i ordning. +func (p *Party) Names() []string { + out := make([]string, len(p.Members)) + for i, m := range p.Members { + out[i] = m.Name + } + return out +} + +// AvgMeters är medelvärdet av allas lägsta mätare, 0..1 (poängen). +func (p *Party) AvgMeters() float64 { + sum := 0.0 + for _, m := range p.Members { + sum += m.Meters.Min() + } + return sum / float64(len(p.Members)) / 100 +} + // NeedsToilet är de som har nytta av en toalett just nu. func (p *Party) NeedsToilet() []*Member { var out []*Member diff --git a/internal/sim/run.go b/internal/sim/run.go index fd7b52d..1829ce9 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -76,8 +76,46 @@ type Run struct { LostReason string // varför banan förlorades (nyckel för UI) FlashKey string // kort meddelande till HUD (textnyckel) FlashAt float64 // < 0 = aldrig + + // Svårighet, poäng och debuffar (M7). + Stats Stats // multiplikatorer från profilens debuffar + Grumpy int // missnöjda med sittplatsen + poisoned bool // otur vid kiosken: matförgiftning efter banan + spentStart int // plånbokens Spent vid banstart + Score ScoreBreakdown + NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt) } +// ScoreBreakdown är poängen för en vunnen bana. +type ScoreBreakdown struct { + Time, Money, Meters, Explore, Penalty, Total int +} + +// Poängformelns vikter. +const ( + ScoreTimeMax = 500 // par eller snabbare + ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan + ScoreMetersMax = 200 // sällskapets mätare vid målgång + ScoreExploreMax = 100 // utforskad andel + PenaltyMissed = 50 + PenaltyRescan = 10 + PenaltyGrumpy = 30 + GrumpyFoodPenalty = 25.0 +) + +// Fler förlustorsaker. +const ( + LostTimeUp = "result.lost_time_up" + LostMissedTrains = "result.lost_missed_trains" +) + +// Fler flash-nycklar. +const ( + FlashGrumpy = "hud.grumpy" + FlashPoisoned = "hud.poisoned" + FlashNoCards = "hud.need_all_cards" +) + // MiniKind är vilket minispel som pågår. type MiniKind int @@ -119,11 +157,12 @@ type FacilityState struct { // RunOptions styr en banomgång. type RunOptions struct { - Speed float64 // rutor per sekund (0 = DefaultSpeed) - Dict *lang.Dictionary // nil = tom ordbok - Know *lang.Knowledge // nil = ny kunskap - Wallet *Wallet // nil = startkassa - Seed rng.Seed // omgångens seed (sparas med spelet) + Speed float64 // rutor per sekund (0 = DefaultSpeed) + Dict *lang.Dictionary // nil = tom ordbok + Know *lang.Knowledge // nil = ny kunskap + Wallet *Wallet // nil = startkassa + Debuffs []Debuff // aktiva debuffar från profilen + Seed rng.Seed // omgångens seed (sparas med spelet) } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -177,7 +216,10 @@ func NewRun(l *level.Level, o RunOptions) *Run { baseSpeed: o.Speed, NoticeAt: -1, Fog: NewFog(l, FogRadius), + Stats: CombinedStats(o.Debuffs), + spentStart: o.Wallet.Spent, } + r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]} r.Fog.Reveal(l, l.SpawnPoint()) if r.parSec <= 0 { n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) @@ -218,9 +260,36 @@ func (r *Run) CanEnter(p level.Point) bool { func (r *Run) canPassGate() bool { if r.Level.TicketRequired { - return r.Wallet.Ticket != "" + if r.Wallet.Ticket == "" { + return false + } + return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size() } - return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen + if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen { + return false + } + if r.Level.IndividualCards { + for _, m := range r.Party.Followers() { + if m.Card < r.Level.FareYen { + return false + } + } + } + return true +} + +// MembersWithoutFare listar följare vars egna kort inte räcker (HUD). +func (r *Run) MembersWithoutFare() []*Member { + if !r.Level.IndividualCards { + return nil + } + var out []*Member + for _, m := range r.Party.Followers() { + if m.Card < r.Level.FareYen { + out = append(out, m) + } + } + return out } func (r *Run) inGate() bool { @@ -240,7 +309,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.Wallet.Drain(dt) before := r.Player.Tile wasGate := r.inGate() - r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() + r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed] if r.Wallet.Phone && r.Wallet.Battery > 0 { r.Player.Speed *= PhoneSpeedFactor } @@ -260,11 +329,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.stepHelp(dt) r.stepFacility(dt) r.stepReading() + r.checkLimits() if r.Player.Tile != before && r.inGate() && !wasGate { r.GatePasses++ if !r.Level.TicketRequired { r.Wallet.PayCard(r.Level.FareYen) - if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance { + if r.Level.IndividualCards { + for _, m := range r.Party.Followers() { + m.Card -= r.Level.FareYen + r.Wallet.Spent += r.Level.FareYen + } + } + if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] { r.Rescans++ r.RescanUntil = r.Time + RescanSec r.flash(FlashRescan) @@ -321,10 +397,22 @@ func (r *Run) startMachine() { } if r.Level.TicketRequired { dest := r.Level.Destination.Get("ja") - r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket + var seats []string + if r.Level.SeatSelection { + seats = r.Party.Names() + } + cash := r.Wallet.Cash + if r.Level.IndividualCards { + cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person + } + r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket return } - r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp + var members []string + if r.Level.IndividualCards { + members = r.Party.Names() + } + r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp } // tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver @@ -348,7 +436,7 @@ func (r *Run) tryBoard(tr *level.Train) { func (r *Run) startRide() { tr := r.PendingTrain if r.Level.TicketRequired { - r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad + r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade } dest := r.Level.Destination.Get("ja") r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide @@ -365,18 +453,43 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { r.Party.Advance(dt, r.Time) r.Mini.Update(in, dt) if !r.Mini.Done() { + r.checkLimits() return } m := r.Mini r.Mini, r.MiniKind = nil, MiniNone switch g := m.(type) { case *minigame.TopUp: - if g.Success() && r.Wallet.TopUp(g.Amount) { + if !g.Success() { + break + } + if g.Member == 0 { + if r.Wallet.TopUp(g.Amount) { + r.TopUps++ + } + } else if r.Wallet.Cash >= g.Amount { + r.Wallet.Cash -= g.Amount + r.Party.Members[g.Member].Card += g.Amount r.TopUps++ } case *minigame.Ticket: - if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) { - r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare + if !g.Success() || g.Chosen == nil { + break + } + count := 1 + if r.Level.IndividualCards { + count = r.Party.Size() + } + if r.Wallet.PayCash(g.Chosen.Fare * count) { + r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count + } + if len(g.Unhappy) > 0 { + r.Grumpy += len(g.Unhappy) + for _, i := range g.Unhappy { + m := r.Party.Members[i] + m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty) + } + r.flash(FlashGrumpy) } case *minigame.Crowd: if g.Success() { @@ -432,8 +545,73 @@ func (r *Run) stepFacility(dt float64) { } // buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet. +// Med liten seedad otur blir maten dålig: matförgiftning efter banan. func (r *Run) buyAtKiosk() { - r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard) + n := r.Party.BuyAtKiosk(r.Wallet.PayCard) + if n == 0 { + return + } + r.Served += n + if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] { + r.poisoned = true + r.flash(FlashPoisoned) + } +} + +// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras. +func (r *Run) checkLimits() { + if r.Outcome != Ongoing { + return + } + if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) { + r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil + r.finish(nil, false, LostTimeUp) + return + } + if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains { + r.finish(nil, false, LostMissedTrains) + } +} + +// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns). +func (r *Run) TimeLeft() float64 { + if r.Level.TimeLimitSec == 0 { + return 0 + } + return max(0, float64(r.Level.TimeLimitSec)-r.Time) +} + +// computeScore fyller i poängen för en vunnen bana. +func (r *Run) computeScore() { + var sc ScoreBreakdown + sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001))) + sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10) + sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters()) + sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level)) + sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy + sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty) + r.Score = sc +} + +// earnedDebuffs räknar ut vilka debuffar banan gav. +func (r *Run) earnedDebuffs() []Debuff { + var out []Debuff + if r.Steps >= SoreFeetSteps { + out = append(out, Debuff{SoreFeet, 1}) + } + if r.Time >= TiredSec { + out = append(out, Debuff{Tired, 1}) + } + for _, m := range r.Party.Members { + if m.Meters.Food <= 0 { + out = append(out, Debuff{Hangry, 1}) + break + } + } + if r.poisoned { + out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels}) + } + return out } // FacilityProgress är 0..1 för ett pågående toalettbesök. @@ -472,12 +650,14 @@ func (r *Run) SignHere() *level.SignText { // tillräckligt bra resultat lär man sig resmålets ord. func (r *Run) finish(tr *level.Train, won bool, reason string) { r.Boarded = tr + r.NewDebuffs = r.earnedDebuffs() if !won { r.Outcome = Lost r.LostReason = reason return } r.Outcome = Won + r.computeScore() r.Stars = r.StarsFor(r.Time) r.PointsEarned = r.Know.Award(r.Stars) if r.Stars >= lang.StarsToLearnDestination { @@ -515,7 +695,7 @@ func (r *Run) stepReading() { continue } seen[w] = true - if roll.Float64() >= lang.ReadChance { + if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] { continue } if r.Know.AddProgress(w) { @@ -547,7 +727,7 @@ func (r *Run) AskHelp() bool { if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing { return false } - secs := lang.HelpSeconds(r.Know.Level()) + secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus] r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs} return true } diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index 4744492..b099cad 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -12,6 +12,19 @@ import ( "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" ) +// fillSeats placerar sällskapet på första lediga platser (sittplatssteget). +func fillSeats(r *Run, tk *minigame.Ticket) { + for r.Mini != nil && tk.Stage == minigame.TicketPickSeat { + for tk.SeatTaken(tk.Seat) { + r.StepMini(frame, minigame.Input{MoveX: 1}) + if tk.Seat.Col == 0 { + r.StepMini(frame, minigame.Input{Move: 1}) + } + } + r.StepMini(frame, minigame.Input{Confirm: true}) + } +} + // ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg // finns inget resmål, så resan går till slutstationen. func ride(t *testing.T, r *Run) { @@ -487,7 +500,7 @@ func TestTopUpFlowAtMachine(t *testing.T) { } cash := r.Wallet.Cash for g.Buttons[g.Cursor] != "チャージ" { - r.StepMini(frame, minigame.Input{Move: 1}) + r.StepMini(frame, minigame.Input{MoveX: 1}) } r.StepMini(frame, minigame.Input{Confirm: true}) r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000) @@ -537,6 +550,7 @@ func TestTicketLevelFlow(t *testing.T) { } wrong := tk.Options[tk.Cursor] r.StepMini(frame, minigame.Input{Confirm: true}) + fillSeats(r, tk) // 002 har sittplatsval if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare { t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash) } @@ -556,6 +570,7 @@ func TestTicketLevelFlow(t *testing.T) { r.StepMini(frame, minigame.Input{Move: 1}) } r.StepMini(frame, minigame.Input{Confirm: true}) + fillSeats(r, tk) if r.Wallet.Ticket != "渋谷" { t.Fatalf("biljett=%q", r.Wallet.Ticket) } @@ -664,3 +679,225 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) { t.Fatal("ingen fartbuff med dött batteri") } } + +func TestTimeLimitAndMissedTrainsLose(t *testing.T) { + l := loadLevel(t, "002-shinjuku") + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) + if r.TimeLeft() != float64(l.TimeLimitSec) { + t.Fatalf("TimeLeft=%.0f", r.TimeLeft()) + } + r.Time = float64(l.TimeLimitSec) - 0.5 + for i := 0; i < 60 && r.Outcome == Ongoing; i++ { + r.Step(frame, None, false) + } + if r.Outcome != Lost || r.LostReason != LostTimeUp || r.Boarded != nil { + t.Fatalf("tidsgräns: outcome=%v reason=%q", r.Outcome, r.LostReason) + } + r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) + r2.MissedTrains = l.MaxMissedTrains + 1 + r2.Step(frame, None, false) + if r2.Outcome != Lost || r2.LostReason != LostMissedTrains { + t.Fatalf("missade tåg: outcome=%v reason=%q", r2.Outcome, r2.LostReason) + } + // Bana 001 har inga gränser. + r3 := NewRun(loadLevel(t, "001-flygplatsen"), RunOptions{Seed: rng.FromInt(1)}) + r3.Time, r3.MissedTrains = 99999, 99 + r3.Step(frame, None, false) + if r3.Outcome != Ongoing || r3.TimeLeft() != 0 { + t.Fatal("utan gränser förlorar man inte på tid eller missade tåg") + } +} + +func TestScoreBreakdown(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)}) + r.Party.nextEventAt = 1e9 + walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + ride(t, r) + sc := r.Score + if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty { + t.Fatalf("score=%+v", sc) + } + if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 { + t.Fatalf("snabb, billig vinst: %+v", sc) + } + if sc.Meters <= 0 || sc.Meters > ScoreMetersMax || sc.Explore <= 0 || sc.Explore > ScoreExploreMax { + t.Fatalf("mätare/utforskat: %+v", sc) + } + // Avdrag räknas. + r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)}) + r2.MissedTrains, r2.Rescans, r2.Grumpy = 1, 2, 1 + r2.Time = 10 * r2.ParSec() + r2.computeScore() + if r2.Score.Penalty != PenaltyMissed+2*PenaltyRescan+PenaltyGrumpy || r2.Score.Time != ScoreTimeMax/10 { + t.Fatalf("avdrag: %+v", r2.Score) + } +} + +func TestDebuffsAffectRunAndAreEarned(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Debuffs: []Debuff{{Blisters, 1}, {FoodPoisoning, 1}, {Hangry, 1}}}) + r.Party.nextEventAt = 1e9 + r.Step(frame, Right, false) + if math.Abs(r.Player.Speed-DefaultSpeed*0.8) > 1e-9 || r.Party.Rates.Toilet != 2 || r.Party.Rates.Thirst != 1.2 { + t.Fatalf("speed=%.2f rates=%+v", r.Player.Speed, r.Party.Rates) + } + r.Step(frame, None, false) + for r.Player.Moving() { + r.Step(frame, None, false) + } + walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter)) + r.AskHelp() + if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 { + t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want) + } + // Intjänade debuffar: många steg, lång tid, tom matmätare. + r.Steps, r.Time = SoreFeetSteps, TiredSec + r.Party.Members[1].Meters.Food = 0 + r.finish(nil, false, LostTimeUp) + kinds := map[DebuffKind]bool{} + for _, d := range r.NewDebuffs { + kinds[d.Kind] = true + } + if !kinds[SoreFeet] || !kinds[Tired] || !kinds[Hangry] || kinds[FoodPoisoning] { + t.Fatalf("intjänade=%v", r.NewDebuffs) + } +} + +func TestIndividualCardsAtGateAndMachine(t *testing.T) { + l := loadLevel(t, "003-shibuya") + if !l.IndividualCards || !l.TicketRequired || !l.SeatSelection || l.Party != 4 { + t.Fatal("003 ska ha egna kort, biljetter, sittplatser och fyra i sällskapet") + } + // Egna kort på en kortbana (utan biljettkrav): alla måste ha saldo. + l1 := loadLevel(t, "001-flygplatsen") + l1.IndividualCards = true + r := NewRun(l1, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) + r.Party.nextEventAt = 1e9 + gate := l1.Find(level.Gate)[0] + anna := r.Party.Members[1] + if anna.Card != MemberCardYen || !r.CanEnter(gate) { + t.Fatalf("Annas kort=%d canEnter=%v", anna.Card, r.CanEnter(gate)) + } + anna.Card = 0 + if r.CanEnter(gate) || len(r.MembersWithoutFare()) != 1 { + t.Fatal("utan saldo på Annas kort stoppar spärren alla") + } + // Fyll på Annas kort i automaten (personval först). + walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter)) + r.Step(frame, None, true) + g := r.Mini.(*minigame.TopUp) + if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 { + t.Fatalf("stage=%v members=%v", g.Stage, g.Members) + } + r.StepMini(frame, minigame.Input{Move: 1}) + r.StepMini(frame, minigame.Input{Confirm: true}) + for g.Buttons[g.Cursor] != "チャージ" { + r.StepMini(frame, minigame.Input{MoveX: 1}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + r.StepMini(frame, minigame.Input{Confirm: true}) + cash := r.Wallet.Cash + for i := 0; i < 600 && r.Mini != nil; i++ { + r.StepMini(frame, minigame.Input{Hold: true}) + } + if anna.Card != 1000 || r.Wallet.Cash != cash-1000 || r.Wallet.Card != StartCardYen { + t.Fatalf("Annas kort=%d cash=%d eget=%d", anna.Card, r.Wallet.Cash, r.Wallet.Card) + } + // Spärren drar från allas kort. + before := r.Wallet.Spent + path := l1.Path(r.Player.Tile, gate, r.CanEnter) + for i, p := range path { + if l1.At(p.X, p.Y) == level.Gate { + path = path[:i+1] + break + } + } + walk(t, r, path) + if anna.Card != 1000-l1.FareYen || r.Wallet.Card != StartCardYen-l1.FareYen || r.Wallet.Spent != before+2*l1.FareYen { + t.Fatalf("efter spärren: anna=%d egen=%d spent=%d", anna.Card, r.Wallet.Card, r.Wallet.Spent) + } +} + +// TestLevel003Flow: biljettbana med egna kort och sittplatser – alla +// behöver biljett, sällskapet placeras, och boten vinner. +func TestLevel003Flow(t *testing.T) { + l := loadLevel(t, "003-shibuya") + r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)}) + r.Party.nextEventAt = 1e9 + machine := l.Find(level.TicketMachine)[0] + walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) + r.Step(frame, None, true) + tk := r.Mini.(*minigame.Ticket) + if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 { + t.Fatalf("members=%v cash=%d", tk.Members, tk.Cash) + } + for tk.Options[tk.Cursor].Dest != "池袋" { + r.StepMini(frame, minigame.Input{Move: 1}) + } + fare := tk.Options[tk.Cursor].Fare + r.StepMini(frame, minigame.Input{Confirm: true}) + if tk.Stage != minigame.TicketPickSeat { + t.Fatal("sittplatsval ska följa") + } + // Placera alla så att önskemålen uppfylls när det går: prova platser tills nöjd. + for cur := 0; r.Mini != nil; cur++ { + best := minigame.Seat{Row: -1} + for row := 0; row < minigame.SeatRows && best.Row < 0; row++ { + for col := 0; col < minigame.SeatCols; col++ { + cand := minigame.Seat{Row: row, Col: col} + if tk.SeatTaken(cand) { + continue + } + trial := append([]minigame.Seat(nil), tk.Assigned...) + trial[cur] = cand + ok := true + for _, p := range tk.Prefs { + if p.Member == cur && !p.Satisfied(trial) { + ok = false + } + } + if ok { + best = cand + break + } + } + } + if best.Row < 0 { + t.Fatal("ingen ledig plats") + } + for tk.Seat != best { + in := minigame.Input{} + if tk.Seat.Row != best.Row { + in.Move = 1 + } else { + in.MoveX = 1 + } + r.StepMini(frame, in) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + } + if r.Wallet.TicketCount != 4 || r.Wallet.Ticket != "池袋" || r.Wallet.Cash != StartCashYen-4*fare { + t.Fatalf("biljetter=%d dest=%q cash=%d", r.Wallet.TicketCount, r.Wallet.Ticket, r.Wallet.Cash) + } + // Den giriga placeringen kan missa "bredvid X" som placeras senare; det + // är tillåtet men ska då synas som missnöje. + if r.Grumpy > 0 && r.FlashKey != FlashGrumpy { + t.Fatal("missnöje ska flashas") + } + goal := l.CorrectPlatform() + walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) + waitForTrain(t, r) + r.Step(frame, None, true) + cr := r.Mini.(*minigame.Crowd) + for i := 0; i < 600 && !cr.InZone(); i++ { + r.StepMini(frame, minigame.Input{}) + } + r.StepMini(frame, minigame.Input{Confirm: true}) + ride(t, r) + if r.Outcome != Won || r.Wallet.TicketCount != 0 { + t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount) + } +} diff --git a/internal/sim/stats.go b/internal/sim/stats.go new file mode 100644 index 0000000..13a1888 --- /dev/null +++ b/internal/sim/stats.go @@ -0,0 +1,121 @@ +package sim + +// Stats är spelarens multiplikatorer: 1,0 = normalt. Debuffar (och i M8 +// items/skills) ändrar dem. +type Stats [5]float64 + +// Statindex. +const ( + StatSpeed = iota // gångfart + StatThirst // hur fort dryckesmätaren sjunker (>1 = fortare) + StatFocus // chans att lära av skyltar, hjälptid (>1 = bättre) + StatToilet // hur fort toamätaren sjunker (>1 = fortare) + StatLuck // omskanning, trängsel (>1 = bättre; items i M8) +) + +// DefaultStats är allt på 1,0. +func DefaultStats() Stats { return Stats{1, 1, 1, 1, 1} } + +// DebuffKind är en typ av debuff (textnyckel: "debuff."). +type DebuffKind string + +// Debuffar. De fyra första uppstår av spelandet (M7), resten delas ut av +// sidouppdrag (M10) och varar 1–4 banor. +const ( + SoreFeet DebuffKind = "sore_feet" // många steg + Tired DebuffKind = "tired" // lång bana + Hangry DebuffKind = "hangry" // tom matmätare + FoodPoisoning DebuffKind = "food_poisoning" // otur vid kiosken + DOMS DebuffKind = "doms" // träningsvärk + Blisters DebuffKind = "blisters" // skoskav + HeavyBag DebuffKind = "heavy_bag" // köpt något tungt/stort + Stuffed DebuffKind = "stuffed" // proppmätt + Sweaty DebuffKind = "sweaty" // svettig och varm +) + +// DebuffEffects är vad varje debuff gör med statsen. +var DebuffEffects = map[DebuffKind]Stats{ + SoreFeet: {0.85, 1, 1, 1, 1}, + Tired: {0.9, 1, 0.8, 1, 1}, + Hangry: {1, 1.2, 0.7, 1, 1}, + FoodPoisoning: {1, 1, 1, 2.0, 1}, + DOMS: {0.9, 1, 1, 1, 1}, + Blisters: {0.8, 1, 1, 1, 1}, + HeavyBag: {0.9, 1.2, 1, 1, 1}, + Stuffed: {0.9, 1, 1, 1.3, 1}, + Sweaty: {1, 1.5, 0.9, 1, 1}, +} + +// Utlösare för de spelgenererade debuffarna. +const ( + SoreFeetSteps = 150 + TiredSec = 300.0 + FoodPoisonChance = 0.05 + FoodPoisonLevels = 2 +) + +// Debuff är en aktiv debuff med återstående antal banor. +type Debuff struct { + Kind DebuffKind `json:"kind"` + LevelsLeft int `json:"levelsLeft"` +} + +// CombinedStats multiplicerar ihop effekterna av alla debuffar. +func CombinedStats(debuffs []Debuff) Stats { + st := DefaultStats() + for _, d := range debuffs { + if e, ok := DebuffEffects[d.Kind]; ok { + for i := range st { + st[i] *= e[i] + } + } + } + return st +} + +// Profile är det som följer spelaren mellan banor och inte hör till +// kunskap eller plånbok: aktiva debuffar (items och skills i M8). +type Profile struct { + Debuffs []Debuff `json:"debuffs"` +} + +// Add lägger till en debuff; finns den redan förlängs den till det längsta. +func (p *Profile) Add(kind DebuffKind, levels int) { + for i := range p.Debuffs { + if p.Debuffs[i].Kind == kind { + p.Debuffs[i].LevelsLeft = max(p.Debuffs[i].LevelsLeft, levels) + return + } + } + p.Debuffs = append(p.Debuffs, Debuff{Kind: kind, LevelsLeft: levels}) +} + +// Has rapporterar om debuffen är aktiv. +func (p *Profile) Has(kind DebuffKind) bool { + for _, d := range p.Debuffs { + if d.Kind == kind { + return true + } + } + return false +} + +// EndLevel räknar ned alla debuffar en bana och tar bort de som gått ut. +// Returnerar de som försvann. +func (p *Profile) EndLevel() []DebuffKind { + var expired []DebuffKind + kept := p.Debuffs[:0] + for _, d := range p.Debuffs { + d.LevelsLeft-- + if d.LevelsLeft <= 0 { + expired = append(expired, d.Kind) + continue + } + kept = append(kept, d) + } + p.Debuffs = kept + return expired +} + +// Stats är de sammanlagda multiplikatorerna just nu. +func (p *Profile) Stats() Stats { return CombinedStats(p.Debuffs) } diff --git a/internal/sim/stats_test.go b/internal/sim/stats_test.go new file mode 100644 index 0000000..00b0251 --- /dev/null +++ b/internal/sim/stats_test.go @@ -0,0 +1,45 @@ +package sim + +import "testing" + +func TestDebuffEffectsAndProfile(t *testing.T) { + if DefaultStats() != (Stats{1, 1, 1, 1, 1}) { + t.Fatal("default") + } + for kind, e := range DebuffEffects { + changed := false + for i := range e { + if e[i] != 1 { + changed = true + } + } + if !changed { + t.Errorf("%s påverkar inget", kind) + } + } + st := CombinedStats([]Debuff{{SoreFeet, 1}, {Blisters, 1}}) + if st[StatSpeed] < 0.67 || st[StatSpeed] > 0.69 || st[StatThirst] != 1 { + t.Fatalf("kombinerad fart %.3f", st[StatSpeed]) + } + + var p Profile + p.Add(Tired, 1) + p.Add(DOMS, 3) + p.Add(Tired, 2) // förlängs, inte dubbleras + if len(p.Debuffs) != 2 || !p.Has(Tired) || p.Debuffs[0].LevelsLeft != 2 { + t.Fatalf("debuffs=%v", p.Debuffs) + } + if p.Stats()[StatSpeed] != 0.9*0.9 { + t.Fatalf("stats=%v", p.Stats()) + } + if exp := p.EndLevel(); len(exp) != 0 || len(p.Debuffs) != 2 { + t.Fatalf("efter bana 1: exp=%v debuffs=%v", exp, p.Debuffs) + } + if exp := p.EndLevel(); len(exp) != 1 || exp[0] != Tired || len(p.Debuffs) != 1 || p.Debuffs[0].Kind != DOMS { + t.Fatalf("efter bana 2: exp=%v debuffs=%v", exp, p.Debuffs) + } + p.EndLevel() + if len(p.Debuffs) != 0 || p.Has(DOMS) { + t.Fatal("DOMS ska ha gått ut efter tre banor") + } +} diff --git a/internal/sim/wallet.go b/internal/sim/wallet.go index b263679..c375de2 100644 --- a/internal/sim/wallet.go +++ b/internal/sim/wallet.go @@ -3,13 +3,14 @@ package sim // Wallet är spelarens pengar och biljetter. Följer med mellan banor // (sparas i M8). type Wallet struct { - Card int `json:"card"` // IC-kortets saldo (yen) - Cash int `json:"cash"` // kontanter (yen) - Phone bool `json:"phone"` // kortet ligger i telefonen - Battery float64 `json:"battery"` // telefonens batteri 0..100 - Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen - TicketFare int `json:"ticketFare"` // vad biljetten kostade - Spent int `json:"spent"` // totalt spenderat (poäng i M7) + Card int `json:"card"` // IC-kortets saldo (yen) + Cash int `json:"cash"` // kontanter (yen) + Phone bool `json:"phone"` // kortet ligger i telefonen + Battery float64 `json:"battery"` // telefonens batteri 0..100 + Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen + TicketFare int `json:"ticketFare"` // vad biljetten kostade + TicketCount int `json:"ticketCount"` // antal biljetter (egna kort: en per person) + Spent int `json:"spent"` // totalt spenderat (poäng i M7) } // Pengar och telefon. diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 65e60c0..1fa634d 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -1,21 +1,11 @@ { - "id": "002-shinjuku", - "name": { - "en": "Shinjuku Station", - "ja": "新宿駅", - "sv": "Shinjuku station" - }, - "difficulty": 2, - "party": 3, - "ticketRequired": true, - "rideStops": 4, "destination": { "en": "Shibuya", "ja": "渋谷", "romaji": "Shibuya", "sv": "Shibuya" }, - "timeLimitSec": 0, + "difficulty": 2, "fareYen": 200, "grid": [ "#################################", @@ -23,7 +13,7 @@ "#.#.#.#.#.#.#######.#.#########.#", "#.#W#.#.#.#.#.....#.#...........#", "#.###.#.#.#.#####.#.###########.#", - "#.#...#.#.........#.#.......#...#", + "#.#...#.#..i......#.#.......#...#", "#.#.###.###########.#.#.###.#.###", "#.....#.......#...#.#.#.........#", "#####.#######.#.#.#.#.#########.#", @@ -35,14 +25,56 @@ "#.#.###.###.#.###.#.#.#######.#.#", "#...#T#.#..i#.#.#...#.....#...#.#", "#####.#.#.#.#.#.#####.#####.###.#", - "#.....#.#.#.#..i....#.......#...#", + "#.....#.#.#.#.......#.......#...#", "#G#.#.#.#.#########.#########.#G#", "#pG.#...#.....................#P#", "#################################" ], + "id": "002-shinjuku", + "maxMissedTrains": 2, + "name": { + "en": "Shinjuku Station", + "ja": "新宿駅", + "sv": "Shinjuku station" + }, + "party": 3, + "rideStops": 4, + "seatSelection": true, + "signs": [ + { + "text": { + "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", + "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", + "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." + }, + "x": 5, + "y": 1 + }, + { + "text": { + "en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e", + "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", + "sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e" + }, + "x": 11, + "y": 15 + }, + { + "text": { + "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", + "ja": "14番線 山手線 渋谷方面 → 南東口 改札", + "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." + }, + "x": 11, + "y": 5 + } + ], + "ticketRequired": true, + "timeLimitSec": 420, "trains": [ { "correct": true, + "crowded": true, "destination": { "en": "Shibuya", "ja": "渋谷", @@ -59,8 +91,7 @@ "intervalSec": 20, "offsetSec": 3 }, - "track": "14", - "crowded": true + "track": "14" }, { "correct": false, @@ -82,34 +113,5 @@ }, "track": "8" } - ], - "signs": [ - { - "text": { - "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", - "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", - "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." - }, - "x": 5, - "y": 1 - }, - { - "text": { - "en": "<- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) ->", - "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", - "sv": "<- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) ->" - }, - "x": 11, - "y": 15 - }, - { - "text": { - "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", - "ja": "14番線 山手線 渋谷方面 → 南東口 改札", - "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." - }, - "x": 15, - "y": 17 - } ] -} +} \ No newline at end of file diff --git a/resources/data/levels/003-shibuya.json b/resources/data/levels/003-shibuya.json new file mode 100644 index 0000000..dd7815c --- /dev/null +++ b/resources/data/levels/003-shibuya.json @@ -0,0 +1,118 @@ +{ + "destination": { + "en": "Ikebukuro", + "ja": "池袋", + "romaji": "Ikebukuro", + "sv": "Ikebukuro" + }, + "difficulty": 3, + "fareYen": 200, + "grid": [ + "#################################", + "#S.........W#....i........#.....#", + "#.#####.#.###.###.#####.#.#.#####", + "#.#...#.#.....#...#.....#.#.....#", + "#.#.#.#.#######.###.#####.#.###.#", + "#.#.#.#.....#...#......i..#.#...#", + "#.#.#.#####.#.#.#.#####.#####.###", + "#...#.#...#...#.#.....#.#...#...#", + "#####.#.#.#####.#####.#.#.#.###.#", + "#T#...#.#...#K#.....#.#...#...#.#", + "#.#.###.###.#.#####.#.#######.#.#", + "#.#.#...#...#...#...#...#...#...#", + "#.#.#.#.#.#.#.###.#####.#.#.#####", + "#...#.#.#.#.#.....#...#...#..i..#", + "#.#####.#.#.#######.#.#######.#.#", + "#.....#.#.#.....#...#...#...#.#.#", + "#####.#.#.#####.#.#####.#.#.#.#.#", + "#...#...#.....#.#.#.#...#.#.#.#.#", + "#G#######.#####.#.#.#.###.#.#.#G#", + "#pG...............#.......#...GP#", + "#################################" + ], + "id": "003-shibuya", + "individualCards": true, + "maxMissedTrains": 1, + "name": { + "en": "Shibuya Station", + "ja": "渋谷駅", + "sv": "Shibuya station" + }, + "party": 4, + "rideStops": 5, + "seatSelection": true, + "signs": [ + { + "text": { + "en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.", + "ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機", + "sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten." + }, + "x": 17, + "y": 1 + }, + { + "text": { + "en": "\u003c- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) -\u003e", + "ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →", + "sv": "\u003c- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) -\u003e" + }, + "x": 23, + "y": 5 + }, + { + "text": { + "en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.", + "ja": "2番線 山手線 池袋方面 → 南東口 改札", + "sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet." + }, + "x": 29, + "y": 13 + } + ], + "ticketRequired": true, + "timeLimitSec": 360, + "trains": [ + { + "correct": true, + "crowded": true, + "destination": { + "en": "Ikebukuro", + "ja": "池袋", + "romaji": "Ikebukuro" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "platform": "P", + "schedule": { + "dwellSec": 7, + "intervalSec": 25, + "offsetSec": 6 + }, + "track": "2" + }, + { + "correct": false, + "destination": { + "en": "Yokohama", + "ja": "横浜", + "romaji": "Yokohama" + }, + "line": { + "en": "Tokyu Toyoko Line", + "ja": "東急東横線", + "romaji": "Tokyu Toyoko-sen" + }, + "platform": "p", + "schedule": { + "dwellSec": 8, + "intervalSec": 30, + "offsetSec": 12 + }, + "track": "4" + } + ] +} \ No newline at end of file diff --git a/resources/text/en.json b/resources/text/en.json index d78973a..cf3011b 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -91,5 +91,41 @@ "mini.crowd.title": "Crowded train - squeeze in", "mini.crowd.hint": "Press Enter when the marker is in the green zone (%d tries left)", "mini.ride.title": "On the train - next stop", - "mini.ride.hint": "Enter: get off here (you are going to %s)" + "mini.ride.hint": "Enter: get off here (you are going to %s)", + "hud.time_left": "Left", + "hud.debuffs": "Debuffs", + "hud.grumpy": "Someone is grumpy about their seat…", + "hud.poisoned": "That snack tasted odd…", + "hud.need_all_cards": "Gate: %s has no money on the card.", + "hud.need_all_tickets": "Gate: everyone needs a ticket.", + "result.lost_time_up": "Time ran out.", + "result.lost_missed_trains": "Too many missed trains.", + "result.score": "Score", + "result.money": "money", + "result.meters": "meters", + "result.new_debuffs": "New debuffs", + "mini.topup.member": "Whose card to top up?", + "mini.seat.hint": "Pick a seat for %s (arrows, Enter)", + "mini.seat.prefs": "Wishes:", + "seat.front": "← direction of travel", + "seat.window": "window", + "seat.aisle": "aisle", + "seat.left": "left side", + "seat.right": "right side", + "seat.with": "next to %s", + "debuff.sore_feet": "sore feet", + "debuff.tired": "tired", + "debuff.hangry": "hangry", + "debuff.food_poisoning": "food poisoning", + "debuff.doms": "sore muscles", + "debuff.blisters": "blisters", + "debuff.heavy_bag": "heavy bag", + "debuff.stuffed": "stuffed", + "debuff.sweaty": "sweaty and hot", + "hud.cash_short": "Cash", + "hud.missed_short": "Miss", + "result.penalty": "penalty", + "result.lang_points": "language points", + "result.missed_short": "Missed", + "result.map": "map" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index 457d2c7..bdc4872 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -481,5 +481,55 @@ "sv": "Nippori", "tier": 3, "kind": "station" + }, + "横浜": { + "kana": "よこはま", + "romaji": "Yokohama", + "en": "Yokohama", + "sv": "Yokohama", + "tier": 3, + "kind": "station" + }, + "東急東横線": { + "kana": "とうきゅうとうよこせん", + "romaji": "Tokyu Toyoko-sen", + "en": "Tokyu Toyoko Line", + "sv": "Tokyu Toyoko-linjen", + "tier": 3 + }, + "渋谷駅": { + "kana": "しぶやえき", + "romaji": "Shibuya-eki", + "en": "Shibuya Station", + "sv": "Shibuya station", + "tier": 3 + }, + "北": { + "kana": "きた", + "romaji": "kita", + "en": "north", + "sv": "norr", + "tier": 2 + }, + "南": { + "kana": "みなみ", + "romaji": "minami", + "en": "south", + "sv": "söder", + "tier": 2 + }, + "東": { + "kana": "ひがし", + "romaji": "higashi", + "en": "east", + "sv": "öster", + "tier": 2 + }, + "西": { + "kana": "にし", + "romaji": "nishi", + "en": "west", + "sv": "väster", + "tier": 2 } } diff --git a/resources/text/sv.json b/resources/text/sv.json index c315b37..c9f8f08 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -91,5 +91,41 @@ "mini.crowd.title": "Fullt tåg - tränga sig på", "mini.crowd.hint": "Tryck Enter när markören är i det gröna fältet (%d försök kvar)", "mini.ride.title": "På tåget - nästa station", - "mini.ride.hint": "Enter: kliv av här (du ska till %s)" + "mini.ride.hint": "Enter: kliv av här (du ska till %s)", + "hud.time_left": "Kvar", + "hud.debuffs": "Debuffar", + "hud.grumpy": "Någon är sur över sittplatsen…", + "hud.poisoned": "Den där matbiten smakade konstigt…", + "hud.need_all_cards": "Spärren: %s har inte pengar på kortet.", + "hud.need_all_tickets": "Spärren: alla behöver en biljett.", + "result.lost_time_up": "Tiden tog slut.", + "result.lost_missed_trains": "För många missade tåg.", + "result.score": "Poäng", + "result.money": "pengar", + "result.meters": "mätare", + "result.new_debuffs": "Nya debuffar", + "mini.topup.member": "Vems kort ska fyllas på?", + "mini.seat.hint": "Välj plats åt %s (pilar, Enter)", + "mini.seat.prefs": "Önskemål:", + "seat.front": "← färdriktning", + "seat.window": "fönster", + "seat.aisle": "gången", + "seat.left": "vänster sida", + "seat.right": "höger sida", + "seat.with": "bredvid %s", + "debuff.sore_feet": "onda fötter", + "debuff.tired": "trötthet", + "debuff.hangry": "hunger/irritation", + "debuff.food_poisoning": "matförgiftning", + "debuff.doms": "träningsvärk", + "debuff.blisters": "skoskav", + "debuff.heavy_bag": "tungt köp", + "debuff.stuffed": "proppmätt", + "debuff.sweaty": "svettig och varm", + "hud.cash_short": "Kont", + "hud.missed_short": "Miss", + "result.penalty": "avdrag", + "result.lang_points": "språkpoäng", + "result.missed_short": "Missade", + "result.map": "karta" } diff --git a/tools/shots/main.go b/tools/shots/main.go index 62e4eb6..20c33ef 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -56,6 +56,14 @@ func buildShots(levelsDir string) error { if err != nil { return err } + l3, err := level.LoadFile(filepath.Join(levelsDir, "003-shibuya.json")) + if err != nil { + return err + } + toMachine3, err := moves(l3, l3.SpawnPoint(), l3.Find(level.TicketMachine)[0]) + if err != nil { + return err + } // Skyltbilderna tas med några kända ord så att markeringen och den // delvisa översättningen syns; hjälpen lär sedan in nästa ord. known := []string{"-know", "改札,番線,方面", "-seed", "7"} @@ -68,6 +76,8 @@ func buildShots(levelsDir string) error { "; waittrain; wait 30; shot $OUT/platform.png; press enter; wait 40; shot $OUT/ride.png; waitstop; press enter; wait 5; shot $OUT/result.png; quit", known}, {"001-flygplatsen", toMachine + "; press enter; wait 5; press right; wait 5; shot $OUT/topup.png; quit", []string{"-seed", "7", "-card", "0"}}, {"002-shinjuku", toMachine2 + "; press enter; wait 5; shot $OUT/ticket.png; quit", []string{"-seed", "7", "-know", "渋谷"}}, + {"003-shibuya", toMachine3 + "; press enter; pick 池袋; press enter; wait 5; press enter; press right; wait 5; shot $OUT/seats.png; quit", + []string{"-seed", "7", "-know", "池袋", "-debuffs", "sore_feet"}}, } return nil }