Files
metro-puzzle-jp/internal/minigame/minigame_test.go
claude b2995d4ca9
All checks were successful
release / build-release (push) Successful in 1m37s
M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som
  multiplikatorer, Profile som följer med mellan banor och räknar ned
- sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel
  (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar
  tjänas av många steg, lång tid, tom matmätare och seedad otur vid
  kiosken; stats påverkar fart, mätartakter, läsning och hjälptid
- egna kort (individualCards): Member.Card, spärren kräver allas saldo och
  drar från alla, automaten frågar vems kort, biljetter köps åt alla
- sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade
  önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag
- level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya
  (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser;
  ordboken utökad; generatorn är spec-driven
- game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och
  förlustorsaker på resultatet, vyer för personval och vagn, skript-pick,
  flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet
- plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort
  och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning,
  roadmap, testplan
- docs/README/wiki med sittplatsbilden

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

296 lines
9.6 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)
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)
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)
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")
}
}