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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:29:30 +02:00

309 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package minigame
import (
"math"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
const frame = 1.0 / 60
func TestTopUpFlowAndDeterminism(t *testing.T) {
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, 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.
wrong := 0
for wrong = range g.Buttons {
if g.Buttons[wrong] != "チャージ" {
break
}
}
g.Cursor = wrong
g.Update(Input{Confirm: true}, frame)
if g.Mistakes != 1 || g.Penalty <= 0 || g.Stage != TopUpPickButton {
t.Fatalf("fel knapp: mistakes=%d penalty=%.1f stage=%v", g.Mistakes, g.Penalty, g.Stage)
}
for g.Penalty > 0 {
g.Update(Input{Move: 1}, frame) // ignoreras under straffet
}
// Rätt knapp -> belopp -> mata sedlar.
for g.Buttons[g.Cursor] != "チャージ" {
g.Update(Input{MoveX: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpPickAmount {
t.Fatal("ska välja belopp nu")
}
g.Update(Input{Move: 1}, frame)
g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpFeed || g.Amount != 2000 {
t.Fatalf("stage=%v amount=%d", g.Stage, g.Amount)
}
for i := 0; i < 60 && !g.Done(); i++ {
g.Update(Input{Hold: true}, frame)
}
if g.Fill < 0.5 || g.Done() {
t.Fatalf("efter 1 s hållning: fill=%.2f done=%v", g.Fill, g.Done())
}
g.Update(Input{}, 0.5) // släppt: rinner tillbaka
if g.Fill >= 0.8 {
t.Fatalf("släppt Enter ska tömma: fill=%.2f", g.Fill)
}
for i := 0; i < 600 && !g.Done(); i++ {
g.Update(Input{Hold: true}, frame)
}
if !g.Done() || !g.Success() || g.Amount != 2000 {
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, nil).Amounts; len(a) != 2 || a[1] != 2000 {
t.Fatalf("amounts med 2500 kr: %v", a)
}
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, nil)
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("avbrutet = klart utan framgång")
}
}
func TestTicketBoardSeededAndAffordable(t *testing.T) {
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))
}
for i := range g.Options {
if g.Options[i] != h.Options[i] {
t.Fatal("samma seed ska ge samma tavla")
}
if f := g.Options[i].Fare; f < 140 || f > 620 || f%10 != 0 {
t.Fatalf("taxa %d", f)
}
}
// Flytta till 渋谷 och köp.
for g.Options[g.Cursor].Dest != "渋谷" {
g.Update(Input{Move: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() || g.Chosen == nil || g.Chosen.Dest != "渋谷" {
t.Fatalf("köp: %+v", g.Chosen)
}
// Utan råd går det inte att köpa.
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")
}
p.Update(Input{Cancel: true}, frame)
if !p.Done() || p.Success() {
t.Fatal("Esc avbryter")
}
}
func TestCrowdZoneAndAttempts(t *testing.T) {
g := NewCrowd(rng.FromInt(7), 2, 1)
if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 {
t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi)
}
// Vänta tills markören är utanför zonen och tryck: ett bommat försök.
for g.InZone() {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Done() || g.Attempts != 1 {
t.Fatalf("done=%v attempts=%d", g.Done(), g.Attempts)
}
// Vänta in zonen och tryck: lyckas.
for i := 0; i < 600 && !g.InZone(); i++ {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() {
t.Fatal("träff i zonen ska lyckas")
}
// Två bom = tåget går.
f := NewCrowd(rng.FromInt(7), 2, 1)
for n := 0; n < 2; n++ {
for f.InZone() {
f.Update(Input{}, frame)
}
f.Update(Input{Confirm: true}, frame)
}
if !f.Done() || f.Success() {
t.Fatal("två bom ska misslyckas")
}
// Markören studsar och håller sig i 0..1.
b := NewCrowd(rng.FromInt(8), 1, 1)
for i := 0; i < 600; i++ {
b.Update(Input{}, frame)
if b.Pos < 0 || b.Pos > 1 {
t.Fatalf("pos=%.2f", b.Pos)
}
}
}
func TestRideStopsAndGettingOff(t *testing.T) {
fillers := []string{"東京", "上野", "新宿", "成田"}
g := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
if len(g.Stops) != 4 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
h := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
if strings.Join(g.Stops, ",") != strings.Join(h.Stops, ",") {
t.Fatal("samma seed = samma linje")
}
// Åk till resmålet och kliv av.
for g.Index != g.Dest {
g.Update(Input{}, frame)
}
g.Update(Input{Confirm: true}, frame)
if !g.Done() || !g.Success() || g.GotOff != g.Dest {
t.Fatal("kliva av på resmålet ska lyckas")
}
// Kliva av för tidigt.
e := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
e.Update(Input{Confirm: true}, frame)
if !e.Done() || e.Success() || e.GotOff != 0 {
t.Fatal("fel station = misslyckat")
}
// Åka förbi.
m := NewRide(rng.FromInt(3), "渋谷", fillers, 4, true)
for i := 0; i < 60*20 && !m.Done(); i++ {
m.Update(Input{}, frame)
}
if !m.Done() || m.Success() || m.Index != 3 {
t.Fatalf("åka förbi: done=%v success=%v index=%d", m.Done(), m.Success(), m.Index)
}
// Fel tåg: resmålet finns inte på linjen, så det går aldrig att lyckas.
w := NewRide(rng.FromInt(3), "渋谷", fillers, 4, false)
if w.Dest != -1 || strings.Contains(strings.Join(w.Stops, ","), "渋谷") {
t.Fatalf("fel tåg: %v", w.Stops)
}
w.Update(Input{Confirm: true}, frame)
if w.Success() {
t.Fatal("på fel tåg kan man inte komma rätt")
}
}
func TestRideWithoutFillersStillHasDestination(t *testing.T) {
g := NewRide(rng.FromInt(1), "渋谷", nil, 3, true)
if len(g.Stops) != 3 || g.Dest < 1 || g.Stops[g.Dest] != "渋谷" || g.Stops[0] != UnknownStop {
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")
}
}
func TestCrowdLuckWidensZone(t *testing.T) {
normal := NewCrowd(rng.FromInt(3), 2, 1)
lucky := NewCrowd(rng.FromInt(3), 2, 1.5)
unlucky := NewCrowd(rng.FromInt(3), 2, 0.5)
if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo {
t.Fatal("tur ska ge bredare zon, otur smalare")
}
huge := NewCrowd(rng.FromInt(3), 2, 100)
if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 {
t.Fatal("zonen klampas")
}
}