package sim import ( "math" "path/filepath" "testing" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "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) { t.Helper() if r.MiniKind != MiniRide { t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind) } for i := 0; i < 60*120 && r.Mini != nil; i++ { g := r.Mini.(*minigame.Ride) r.StepMini(frame, minigame.Input{Confirm: g.Index == g.Dest}) } if r.Mini != nil { t.Fatal("resan tog aldrig slut") } } func loadDict(t *testing.T) *lang.Dictionary { t.Helper() b, err := res.ReadFile("text", "jp-dictionary.json") if err != nil { t.Fatal(err) } d, err := lang.ParseDictionary(b) if err != nil { t.Fatal(err) } return d } const frame = 1.0 / 60 func loadLevel(t *testing.T, id string) *level.Level { t.Helper() dir, err := res.Path("data", "levels") if err != nil { t.Fatal(err) } l, err := level.LoadFile(filepath.Join(dir, id+".json")) if err != nil { t.Fatal(err) } return l } func dirTo(from, to level.Point) Dir { switch { case to.X > from.X: return Right case to.X < from.X: return Left case to.Y > from.Y: return Down default: return Up } } // walk låter en bot följa en väg ruta för ruta, en bildruta i taget. Tangenten // släpps så fort steget mot nästa ruta påbörjats, så att boten stannar exakt // där även när farten inte är bildruteexakt (telefon- eller sällskapsfaktor). func walk(t *testing.T, r *Run, path []level.Point) { t.Helper() for _, next := range path[1:] { d := dirTo(r.Player.Tile, next) for i := 0; i < 600; i++ { if r.Player.Tile == next && !r.Player.Moving() { break } if r.Player.Target == next || r.Player.Tile == next { r.Step(frame, None, false) } else { r.Step(frame, d, false) } } if r.Player.Tile != next || r.Player.Moving() { t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next) } } } // waitForTrain står still tills tåget vid perrongen är inne. func waitForTrain(t *testing.T, r *Run) *level.Train { t.Helper() for i := 0; i < 60*120; i++ { tr, at, _ := r.PlatformHere() if tr == nil { t.Fatalf("står inte på en perrong: %v", r.Player.Tile) } if at { return tr } r.Step(frame, None, false) } t.Fatal("tåget kom aldrig") return nil } // TestBotWinsLevel001 är simuleringstestet för M2: en bot går kortaste vägen // från start till rätt perrong i bana 001, passerar spärren, väntar in tåget // och kliver på – helt utan fönster. func TestBotWinsLevel001(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.Platform)[0] path := l.Path(r.Player.Tile, goal, r.CanEnter) if path == nil { t.Fatal("ingen väg till perrongen") } walk(t, r, path) if r.Outcome != Ongoing { t.Fatal("banan ska inte vara avgjord innan man kliver på") } r.Step(frame, None, true) // Enter medan tåget kanske inte är inne får inte avgöra fel tr := waitForTrain(t, r) if !tr.Correct { t.Fatal("rätt perrong ska ha rätt tåg") } r.Step(frame, None, true) if r.Outcome != Ongoing || r.MiniKind != MiniRide { t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind) } ride(t, r) if r.Outcome != Won || r.Boarded != tr { t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded) } if r.GatePasses != 1 || r.Wallet.Card != StartCardYen-l.FareYen { t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Wallet.Card, StartCardYen-l.FareYen) } if r.Steps != len(path)-1 { t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1) } if r.Stars != 3 || r.PointsEarned != 3*lang.PointsPerStar || r.Know.Points != r.PointsEarned { t.Fatalf("snabb vinst ska ge 3 stjärnor: stars=%d points=%d", r.Stars, r.PointsEarned) } // Efter utfall händer inget mer. before := r.Time r.Step(frame, Right, true) if r.Time != before { t.Fatal("Step ska vara en no-op när banan är avgjord") } } func TestBotLosesOnWrongTrain(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.WrongPlatform)[0] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) tr := waitForTrain(t, r) r.Step(frame, None, true) ride(t, r) if r.Outcome != Lost || r.Boarded != tr || tr.Correct || r.LostReason != LostWrongTrain { t.Fatalf("outcome=%v boarded=%+v reason=%q", r.Outcome, r.Boarded, r.LostReason) } } func TestGateNeedsCardBalance(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) gate := l.Find(level.Gate)[0] r.Wallet.Card = 0 if r.CanEnter(gate) { t.Fatal("spärren ska stoppa ett tomt kort") } if r.DeniedAt < 0 { t.Fatal("nekandet ska registreras för HUD:en") } if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil { t.Fatal("utan saldo ska perrongen vara onåbar") } r.Wallet.Card = l.FareYen if !r.CanEnter(gate) { t.Fatal("med exakt avgiften ska spärren släppa igenom") } // Mellan två spärrutor är det gratis: ställ spelaren i spärren. r.Player.Tile, r.Player.Target = gate, gate r.Wallet.Card = 0 other := level.Point{X: gate.X + 1, Y: gate.Y} if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) { t.Fatal("att gå mellan två spärrutor ska inte kosta") } } func TestBoardOnlyWhenTrainPresent(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.Find(level.Platform)[0] walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) // Spola fram till en tidpunkt då tåget är borta. for { _, at, _ := r.PlatformHere() if !at { break } r.Step(frame, None, false) } r.Step(frame, None, true) if r.Outcome != Ongoing { t.Fatal("man ska inte kunna kliva på ett tåg som inte är inne") } } func TestSignHereOnlyWhenStill(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[0] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" { t.Fatal("stilla på skyltrutan ska ge skylten") } r.Step(frame, Left, false) if r.Player.Moving() && r.SignHere() != nil { t.Fatal("i rörelse visas ingen skylt") } } // TestAskHelpRevealsSignAndTeachesWord: att fråga om hjälp tar // lang.HelpSeconds, avslöjar skylten och lär spelaren ett ord från den. func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) { l := loadLevel(t, "001-flygplatsen") know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[1] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) if !r.AskHelp() { t.Fatal("ska kunna fråga om hjälp på en skylt") } if r.AskHelp() { t.Fatal("inte två hjälp samtidigt") } want := lang.HelpSeconds(0) if math.Abs(r.Help.Total-want) > 1e-9 { t.Fatalf("hjälptid %.1f, vill ha %.1f", r.Help.Total, want) } frames := int(want/frame) - 2 for i := 0; i < frames; i++ { r.Step(frame, None, false) } if !r.Help.Active || r.SignRevealed(sign) || r.HelpProgress() < 0.9 { t.Fatalf("strax före klart: active=%v revealed=%v progress=%.2f", r.Help.Active, r.SignRevealed(sign), r.HelpProgress()) } for i := 0; i < 4; i++ { r.Step(frame, None, false) } if r.Help.Active || !r.SignRevealed(sign) { t.Fatal("hjälpen ska vara klar och skylten avslöjad") } if know.Count() != 1 || r.Learned == "" || !know.Knows(r.Learned) || know.Helps != 1 { t.Fatalf("lärde sig fel: count=%d learned=%q helps=%d", know.Count(), r.Learned, know.Helps) } if first := lang.FirstUnknownWord(r.Dict, lang.NewKnowledge(), l.SignAt(sign).Text["ja"]); first != r.Learned { t.Fatalf("ska lära sig första okända ordet %q, lärde sig %q", first, r.Learned) } if r.AskHelp() { t.Fatal("en avslöjad skylt behöver ingen hjälp") } } func TestHelpIsCancelledByWalkingAway(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) sign := l.Find(level.Sign)[1] walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) r.AskHelp() r.Step(frame, Left, false) if r.Help.Active { t.Fatal("att gå iväg ska avbryta hjälpen") } if r.Know.Count() != 0 || r.SignRevealed(sign) { t.Fatal("inget lärt, inget avslöjat") } } func TestNoHelpOffSign(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) if r.AskHelp() { t.Fatal("ingen skylt på startrutan") } } func TestStarsFromParTime(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) par := r.ParSec() n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) - 1 if want := float64(n)*ParSecPerTile + ParSlackSec; par != want { t.Fatalf("par=%.1f, vill ha %.1f (%d rutor)", par, want, n) } if r.StarsFor(par) != 3 || r.StarsFor(par*1.5) != 2 || r.StarsFor(par*1.51) != 1 { t.Fatal("stjärntrösklarna") } l.ParSec = 40 if NewRun(l, RunOptions{}).ParSec() != 40 { t.Fatal("parSec i bandatan ska vinna") } } // TestGoodResultTeachesDestination: tre stjärnor lär ut resmålets ord (新宿), // en långsam vinst gör det inte men ger ändå en poäng. func TestGoodResultTeachesDestination(t *testing.T) { l := loadLevel(t, "001-flygplatsen") for _, tc := range []struct { name string slow bool stars int knowsDst bool }{{"snabb", false, 3, true}, {"långsam", true, 1, false}} { know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(2)}) walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) waitForTrain(t, r) if tc.slow { r.Time = 10 * r.ParSec() waitForTrain(t, r) } r.Step(frame, None, true) ride(t, r) if r.Outcome != Won || r.Stars != tc.stars { t.Fatalf("%s: outcome=%v stars=%d", tc.name, r.Outcome, r.Stars) } if know.Knows("新宿") != tc.knowsDst || (tc.knowsDst && (len(r.LearnedDest) != 1 || r.LearnedDest[0] != "新宿")) { t.Fatalf("%s: knows 新宿=%v learnedDest=%v", tc.name, know.Knows("新宿"), r.LearnedDest) } if know.Points != tc.stars*lang.PointsPerStar { t.Fatalf("%s: poäng=%d", tc.name, know.Points) } } } // TestReadingGivesSeededProgress: att stanna på en skylt ger okända ord en // chans till framsteg; samma seed ger exakt samma utfall, och efter många // besök har något ord lossnat. func TestReadingGivesSeededProgress(t *testing.T) { l := loadLevel(t, "001-flygplatsen") sign := l.Find(level.Sign)[1] left := level.Point{X: sign.X - 1, Y: sign.Y} play := func(seed uint64) *lang.Knowledge { know := lang.NewKnowledge() r := NewRun(l, RunOptions{Dict: loadDict(t), Know: know, Seed: rng.FromInt(seed)}) walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter)) for i := 0; i < 15; i++ { // gå av och på skylten = nya besök walk(t, r, []level.Point{sign, left}) walk(t, r, []level.Point{left, sign}) } if r.Visits[sign] != 16 { t.Fatalf("besök=%d", r.Visits[sign]) } return know } a, b := play(5), play(5) if a.Count() != b.Count() || len(a.Progress) != len(b.Progress) { t.Fatalf("samma seed ska ge samma inlärning: %v/%v vs %v/%v", a.Words, a.Progress, b.Words, b.Progress) } for w, n := range a.Progress { if b.Progress[w] != n { t.Fatalf("framsteg skiljer sig för %s", w) } } if a.Count() == 0 { t.Fatalf("efter 16 läsningar borde minst ett ord ha lossnat: progress=%v", a.Progress) } for w := range a.Words { if a.ProgressOf(w) != 0 { t.Fatalf("känt ord %s ska inte ha framsteg kvar", w) } } } func TestToiletAndKioskInRun(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) if r.Party.Size() != l.Party || r.Party.Size() < 2 { t.Fatalf("sällskap=%d, banan säger %d", r.Party.Size(), l.Party) } anna := r.Party.Members[1] r.Party.nextEventAt = 1e9 // inga slumphändelser som stör testet // Toalett: tar ToiletSecPerMember per person, avbryts om man går. toilet := l.Find(level.Toilet)[0] walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter)) anna.Meters.Toilet = 10 anna.Urge = NeedToilet r.Step(frame, None, true) if !r.Facility.Active || r.Facility.Count != 1 || r.Facility.Total != ToiletSecPerMember { t.Fatalf("toalettbesök: %+v", r.Facility) } r.Step(frame, Right, false) if r.Facility.Active { t.Fatal("att gå avbryter besöket") } for r.Player.Moving() { // gå klart rutan innan vi vänder tillbaka r.Step(frame, None, false) } walk(t, r, l.Path(r.Player.Tile, toilet, r.CanEnter)) r.Step(frame, None, true) if !r.Facility.Active { t.Fatal("besöket ska starta igen") } for i := 0; i < int(ToiletSecPerMember/frame)+2 && r.Facility.Active; i++ { r.Step(frame, None, false) } if r.Facility.Active || anna.Meters.Toilet != 100 || anna.Urge != NoUrge || r.Served != 1 { t.Fatalf("efter besöket: active=%v toa=%.0f urge=%v served=%d", r.Facility.Active, anna.Meters.Toilet, anna.Urge, r.Served) } // Kiosk: direkt, kostar KioskPriceYen per person från kortet. kiosk := l.Find(level.Kiosk)[0] walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter)) anna.Meters.Drink = 5 anna.Urge = Thirsty before := r.Wallet.Card r.Step(frame, None, true) if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Wallet.Card != before-KioskPriceYen || r.Served != 2 { t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Wallet.Card, r.Served) } } func TestPartySlowsLeader(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) r.Party.Members[1].Urge = Distracted r.Party.Members[1].UrgeAt = 1000 // går inte över under testet r.Step(frame, Right, false) if r.Player.Speed != DefaultSpeed*DistractedSpeedFactor { t.Fatalf("fart=%.2f", r.Player.Speed) } } func TestRideWrongOrMissedStopLoses(t *testing.T) { l := loadLevel(t, "001-flygplatsen") for _, tc := range []struct { name string getOff bool reason string }{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} { r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) waitForTrain(t, r) r.Step(frame, None, true) g := r.Mini.(*minigame.Ride) if g.Dest < 1 { t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest) } if tc.getOff { r.StepMini(frame, minigame.Input{Confirm: true}) // första stationen = fel } else { for i := 0; i < 60*120 && r.Mini != nil; i++ { r.StepMini(frame, minigame.Input{}) } } if r.Outcome != Lost || r.LostReason != tc.reason { t.Fatalf("%s: outcome=%v reason=%q", tc.name, r.Outcome, r.LostReason) } } } func TestTopUpFlowAtMachine(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)}) r.Wallet.Card = 0 gate := l.Find(level.Gate)[0] if r.CanEnter(gate) { t.Fatal("tomt kort stoppas") } machine := l.Find(level.TicketMachine)[0] walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) r.Step(frame, None, true) g, ok := r.Mini.(*minigame.TopUp) if !ok || r.MiniKind != MiniTopUp { t.Fatalf("automaten ska starta påfyllning: %T", r.Mini) } cash := r.Wallet.Cash 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}) // första beloppet (1000) for i := 0; i < 600 && r.Mini != nil; i++ { r.StepMini(frame, minigame.Input{Hold: true}) } if r.Mini != nil || r.Wallet.Card != 1000 || r.Wallet.Cash != cash-1000 || r.TopUps != 1 { t.Fatalf("efter påfyllning: card=%d cash=%d topups=%d", r.Wallet.Card, r.Wallet.Cash, r.TopUps) } if !r.CanEnter(gate) { t.Fatal("med påfyllt kort ska spärren släppa igenom") } // Minispelet pausar rörelsen men inte klockan. r.Step(frame, None, true) t0 := r.Time r.Step(frame, Right, false) if r.Mini == nil || r.Time != t0 || r.Player.Moving() { t.Fatal("under minispelet ska Step vara en no-op") } r.StepMini(frame, minigame.Input{Cancel: true}) if r.Mini != nil || r.Time <= t0 { t.Fatal("Esc avbryter och klockan går") } } // TestTicketLevelFlow spelar bana 002: köp rätt biljett, passera spärren, // tränga sig på det fulla tåget och kliva av i Shibuya. func TestTicketLevelFlow(t *testing.T) { l := loadLevel(t, "002-shinjuku") if !l.TicketRequired || !l.Trains[0].Crowded { t.Fatal("002 ska kräva biljett och ha ett fullt tåg") } r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)}) r.Party.nextEventAt = 1e9 gate := l.Find(level.Gate)[0] if r.CanEnter(gate) { t.Fatal("utan biljett stoppar spärren även med kort") } machine := l.Find(level.TicketMachine)[0] walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) // Fel biljett först: tåget vägrar. r.Step(frame, None, true) tk := r.Mini.(*minigame.Ticket) for tk.Options[tk.Cursor].Dest == "渋谷" { r.StepMini(frame, minigame.Input{Move: 1}) } 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) } goal := l.CorrectPlatform() walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) waitForTrain(t, r) r.Step(frame, None, true) if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing { t.Fatal("fel biljett ska stoppas vid tåget") } // Rätt biljett. walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter)) r.Step(frame, None, true) tk = r.Mini.(*minigame.Ticket) for tk.Options[tk.Cursor].Dest != "渋谷" { 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) } walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter)) waitForTrain(t, r) r.Step(frame, None, true) cr, ok := r.Mini.(*minigame.Crowd) if !ok { t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini) } for i := 0; i < 600 && !cr.InZone(); i++ { r.StepMini(frame, minigame.Input{}) } r.StepMini(frame, minigame.Input{Confirm: true}) if r.MiniKind != MiniRide || r.Wallet.Ticket != "" { t.Fatalf("efter trängseln: kind=%v biljett=%q", r.MiniKind, r.Wallet.Ticket) } ride(t, r) if r.Outcome != Won { t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason) } } func TestCrowdFailureMissesTrain(t *testing.T) { l := loadLevel(t, "002-shinjuku") r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)}) r.Party.nextEventAt = 1e9 r.Wallet.Ticket = "渋谷" 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 n := 0; n < CrowdTries; n++ { for cr.InZone() { r.StepMini(frame, minigame.Input{}) } r.StepMini(frame, minigame.Input{Confirm: true}) } if r.Mini != nil || r.MissedTrains != 1 || r.Outcome != Ongoing || r.FlashKey != FlashMissedTrain { t.Fatalf("missat tåg: mini=%v missed=%d outcome=%v", r.Mini, r.MissedTrains, r.Outcome) } if r.Wallet.Ticket != "渋谷" { t.Fatal("biljetten ska vara kvar när man inte kom med") } } func TestPhoneSpeedRescanAndBattery(t *testing.T) { l := loadLevel(t, "001-flygplatsen") rescans := map[int]int{} for seed := uint64(1); seed <= 12; seed++ { r := NewRun(l, RunOptions{Seed: rng.FromInt(seed)}) r.Party.nextEventAt = 1e9 r.Wallet.Phone = true r.Step(frame, Right, false) if math.Abs(r.Player.Speed-DefaultSpeed*PhoneSpeedFactor) > 1e-9 { t.Fatalf("telefonen ska ge fartbuff: %.2f", r.Player.Speed) } for r.Player.Moving() { // gå klart rutan innan vi väljer väg r.Step(frame, None, false) } gate := l.Find(level.Gate)[0] path := l.Path(r.Player.Tile, gate, r.CanEnter) for i, p := range path { // stanna på den första spärrutan – där sker skanningen if l.At(p.X, p.Y) == level.Gate { path = path[:i+1] break } } walk(t, r, path) rescans[r.Rescans]++ if r.Rescans == 1 { if r.Time >= r.RescanUntil || r.FlashKey != FlashRescan { t.Fatal("omskanning ska frysa spelaren en stund") } before := r.Player.Tile r.Step(frame, Right, false) if r.Player.Moving() || r.Player.Tile != before { t.Fatal("under omskanningen står man still") } } } if rescans[1] == 0 || rescans[0] == 0 { t.Fatalf("på 12 seeds borde några men inte alla ge omskanning: %v", rescans) } // Determinism: samma seed -> samma utfall. run := func() int { r := NewRun(l, RunOptions{Seed: rng.FromInt(5)}) r.Party.nextEventAt = 1e9 r.Wallet.Phone = true walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.CanEnter)) return r.Rescans } if run() != run() { t.Fatal("omskanningen ska vara seedad") } // Dött batteri: kortet i telefonen går inte -> spärren stoppar. r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) r.Wallet.Phone = true r.Wallet.Battery = 0.001 r.Step(frame, None, false) if r.Wallet.Battery != 0 || r.CanEnter(l.Find(level.Gate)[0]) { t.Fatal("dött batteri ska stoppa spärren") } if r.Player.Speed != DefaultSpeed { 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) } } func TestNearTrackAndPhases(t *testing.T) { l := loadLevel(t, "001-flygplatsen") r := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) r.Party.nextEventAt = 1e9 if _, _, _, _, _, ok := r.NearTrack(2); ok { t.Fatal("från starten är ingen perrong nära") } walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter)) tr, p, phase, prog, dist, ok := r.NearTrack(2) if !ok || dist != 0 || p != l.CorrectPlatform() || !tr.Correct || prog < 0 || prog > 1 { t.Fatalf("ok=%v dist=%d p=%v", ok, dist, p) } if want, _ := tr.Schedule.Phase(r.Time); want != phase { t.Fatal("fasen ska komma från tidtabellen") } // Ombordstigning bara med öppna dörrar: under inrullningen vägras man. for { ph, _ := r.PlatformPhase(level.Platform) if ph == level.Arriving { break } r.Step(frame, None, false) } if _, at, _ := r.PlatformHere(); at || !r.TrainVisible(level.Platform) { t.Fatal("under inrullningen syns tåget men dörrarna är stängda") } r.Step(frame, None, true) if r.Mini != nil { t.Fatal("man kan inte kliva på ett tåg som rullar in") } for { ph, _ := r.PlatformPhase(level.Platform) if ph == level.Dwelling { break } r.Step(frame, None, false) } if _, at, _ := r.PlatformHere(); !at { t.Fatal("med öppna dörrar kan man kliva på") } // Står man två rutor bort ser man tåget komma; tre rutor bort inte. r2 := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) goal := l.CorrectPlatform() r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X, Y: goal.Y - 2}, level.Point{X: goal.X, Y: goal.Y - 2} if _, _, _, _, d, ok := r2.NearTrack(2); !ok || d != 2 { t.Fatalf("två rutor bort: ok=%v d=%d", ok, d) } r2.Player.Tile, r2.Player.Target = level.Point{X: goal.X - 3, Y: goal.Y}, level.Point{X: goal.X - 3, Y: goal.Y} if _, _, _, _, _, ok := r2.NearTrack(2); ok { t.Fatal("tre rutor bort är utom räckhåll") } }