Files
metro-puzzle-jp/internal/sim/run_test.go
claude f04d6a88f9
All checks were successful
release / build-release (push) Successful in 2m7s
M10c: vem betalar i kiosken och spärrar utan återvändo
Björns tillägg 2026-10-24. Med egna kort och fler i sällskapet frågar
kiosken vem som betalar (minigame.Payer: kontant, spelarens kort eller en
följares kort; den som inte har råd nekas, Esc avbryter). Avgiften dras
nu när man passerar spärren in i det betalda området (level.InsideGates),
inte på spärrrutan. Banflaggan oneWayGates: incheckad släpps inte ut igen
förrän man åkt; Enter vid spärren kallar på personal (10 s) som låser upp
korten; ny inpassering kostar igen. På i 005, 007/007b och 008/008b.
Skriptkommandon urge och push. Docs, wiki och skärmdumpar uppdaterade.

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

1190 lines
40 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 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})
}
}
// pickCar väljer vagn: låter folk kliva av, väljer sedan den lugnaste vagnen
// som inte är kvinnovagn. Hanterar också ett ev. trängselminispel.
func pickCar(t *testing.T, r *Run) {
t.Helper()
g, ok := r.Mini.(*minigame.CarPick)
if !ok {
t.Fatalf("ingen vagnval pågår (kind=%v)", r.MiniKind)
}
for g.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
best := -1
for i, c := range g.Cars {
if c.WomenOnly {
continue
}
if best < 0 || c.Crowd < g.Cars[best].Crowd {
best = i
}
}
if best < 0 {
best = 0
}
for g.Cursor != best {
r.StepMini(frame, minigame.Input{MoveX: 1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
if cr, ok := r.Mini.(*minigame.Crowd); ok {
for i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
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. Släpper förbi folk.
func ride(t *testing.T, r *Run) {
t.Helper()
if r.MiniKind == MiniCarPick {
pickCar(t, r)
}
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)
in := minigame.Input{Confirm: g.Index == g.Dest}
if g.NeedsLetPass() {
in.MoveX = -1
}
r.StepMini(frame, in)
}
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).
// Trappor: att kliva på en trappruta flyttar en till andra änden i samma
// steg, så vägens nästa punkt (motparten) är redan nådd.
func walk(t *testing.T, r *Run, path []level.Point) {
t.Helper()
for idx := 1; idx < len(path); idx++ {
next := path[idx]
cur := r.Player.Tile
if next.Z != cur.Z && next.X == cur.X && next.Y == cur.Y {
continue // våningsbytet skedde när vi klev på trappan
}
d := dirTo(cur, next)
arrived := func() bool {
p := r.Player.Tile
return !r.Player.Moving() && (p == next || (r.Level.At(next).IsStairs() && p.X == next.X && p.Y == next.Y))
}
for i := 0; i < 600 && !arrived(); i++ {
if r.Player.Target == next || r.Player.Tile == next || (r.Player.Tile.X == next.X && r.Player.Tile.Y == next.Y) {
r.Step(frame, None, false)
} else {
r.Step(frame, d, false)
}
}
if !arrived() {
t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next)
}
}
}
// throughGate kapar en väg vid första rutan innanför spärrarna – där dras
// avgiften (inte på spärrrutan).
func throughGate(l *level.Level, path []level.Point) []level.Point {
in := l.InsideGates()
for i, p := range path {
if in[p] {
return path[:i+1]
}
}
if len(path) > 0 { // vägen slutar på spärren: kliv in på rutan innanför
for _, n := range l.Neighbors(path[len(path)-1]) {
if in[n] {
return append(path, n)
}
}
}
return path
}
// 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.PathCanEnter)
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 != MiniCarPick {
t.Fatalf("att kliva på ska börja med vagnvalet: 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.PathCanEnter))
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.PathCanEnter) != 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) == 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.PathCanEnter))
// 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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
pickCar(t, r)
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.PathCanEnter))
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.PathCanEnter))
// 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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cp, ok := r.Mini.(*minigame.CarPick)
if !ok {
t.Fatalf("ombordstigning börjar med vagnval, fick %T", r.Mini)
}
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: 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.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cp := r.Mini.(*minigame.CarPick)
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: 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.PathCanEnter)
path = throughGate(l, path) // in i det betalda området: där dras avgiften och sker skanningen
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.PathCanEnter))
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.PathCanEnter))
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.Car-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.PathCanEnter))
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.PathCanEnter))
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.PathCanEnter)
path = throughGate(l1, path) // in i det betalda området: där dras avgiften och sker skanningen
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.PathCanEnter))
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.PathCanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
ride(t, r) // vagnval, ev. trängsel, resa
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.PathCanEnter))
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")
}
}
func TestRunOptionsFromProgression(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
extra := DefaultStats()
extra[StatSpeed] = 1.2
r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Extra: extra, FogRadius: 2, Landmarks: true, FreeGates: true, RevealAll: true, InfiniteBattery: true})
r.Party.nextEventAt = 1e9
r.Wallet.Card, r.Wallet.Phone, r.Wallet.Battery = 0, true, 50
r.Step(frame, Right, false)
if math.Abs(r.Player.Speed-DefaultSpeed*1.2*PhoneSpeedFactor) > 1e-9 {
t.Fatalf("extra fart: %.2f", r.Player.Speed)
}
if r.Wallet.Battery != 50 {
t.Fatal("powerbank: batteriet laddar inte ur")
}
if r.Fog.Radius != FogRadius+2 || !r.Fog.ExtraLandmarks || r.Fog.Explored(l) != 1 {
t.Fatalf("dimma: radius=%d landmarks=%v explored=%.2f", r.Fog.Radius, r.Fog.ExtraLandmarks, r.Fog.Explored(l))
}
gate := l.Find(level.Gate)[0]
if !r.CanEnter(gate) {
t.Fatal("JR Pass: spärren släpper igenom utan saldo")
}
for r.Player.Moving() {
r.Step(frame, None, false)
}
path := l.Path(r.Player.Tile, gate, r.PathCanEnter)
path = throughGate(l, path) // in i det betalda området: där dras avgiften och sker skanningen
walk(t, r, path)
if r.Wallet.Spent != 0 || r.GatePasses != 1 {
t.Fatalf("gratis spärr: spent=%d passes=%d", r.Wallet.Spent, r.GatePasses)
}
// Utan extras: standard.
plain := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
if plain.Stats != DefaultStats() || plain.Fog.Radius != FogRadius || plain.Fog.ExtraLandmarks {
t.Fatal("standardvärden utan progression")
}
// Lokalkännedom: kiosken syns på minikartan utan att vara sedd.
k := l.Find(level.Kiosk)[0]
if plain.Fog.Visible(l, k) || !r.Fog.Visible(l, k) {
t.Fatal("landmärken från skillen")
}
}
const stairsLevel = `{
"id": "stairs", "name": {"sv": "Trappor"}, "difficulty": 1, "destination": {"sv": "x", "ja": "新宿"},
"floors": [
{"name": "B1", "grid": ["#######", "#S..U.#", "#######"]},
{"name": "1F", "grid": ["#######", "#.P.D.#", "#######"]}
],
"trains": [{"platform": "P", "track": "1", "line": {"en": "A"}, "destination": {"en": "B", "ja": "新宿"}, "correct": true}]
}`
func TestStairsMoveBetweenFloors(t *testing.T) {
l, err := level.Parse([]byte(stairsLevel))
if err != nil {
t.Fatal(err)
}
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
if r.Player.Tile.Z != 0 || r.Fog.Seen(level.Point{X: 2, Y: 1, Z: 1}) {
t.Fatal("startar på B1; 1F är inte sedd än (utom trappans ände)")
}
walk(t, r, []level.Point{pt(1, 1), pt(2, 1), pt(3, 1)})
if !r.Fog.Seen(level.Point{X: 4, Y: 1, Z: 1}) {
t.Fatal("nära trappan avslöjas dess andra ände")
}
// Gå in på trappan: hamnar på 1F i trappan ner.
for i := 0; i < 120 && r.Player.Tile.Z == 0; i++ {
r.Step(frame, Right, false)
}
if r.Player.Tile != (level.Point{X: 4, Y: 1, Z: 1}) || r.Player.Moving() || r.FloorChanges != 1 {
t.Fatalf("efter trappan: %v moving=%v changes=%d", r.Player.Tile, r.Player.Moving(), r.FloorChanges)
}
if _, _, _, _, _, ok := r.NearTrack(2); !ok {
t.Fatal("på 1F är perrongen nära")
}
// Dimman på 1F är nu avslöjad runt trappan.
if !r.Fog.Seen(level.Point{X: 2, Y: 1, Z: 1}) {
t.Fatal("dimman på 1F ska lätta runt trappan")
}
// Tillbaka ner: kliv av trappan och gå på den igen.
walk(t, r, []level.Point{r.Player.Tile, {X: 3, Y: 1, Z: 1}})
walk(t, r, []level.Point{{X: 3, Y: 1, Z: 1}, {X: 4, Y: 1, Z: 1}})
if r.Player.Tile != (level.Point{X: 4, Y: 1, Z: 0}) || r.FloorChanges != 2 {
t.Fatalf("tillbaka till B1: %v changes=%d", r.Player.Tile, r.FloorChanges)
}
// Hela vägen till perrongen med BFS över våningar. Man står i trappans
// nedre ände, så kliv av först – trappan tar en när man kliver PÅ den.
walk(t, r, []level.Point{r.Player.Tile, {X: 3, Y: 1, Z: 0}})
path := l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter)
if path == nil {
t.Fatal("ingen väg")
}
walk(t, r, path)
if r.Player.Tile != l.CorrectPlatform() {
t.Fatalf("boten nådde inte perrongen: %v", r.Player.Tile)
}
}
func TestCarsWomenOnlyEarlyAndCrowd(t *testing.T) {
l := loadLevel(t, "005-ueno")
tr := &l.Trains[0]
if tr.Cars != 4 || tr.WomenOnlyCar != 1 || !tr.WomenOnlyActive(10) || tr.WomenOnlyActive(200) {
t.Fatal("005: fyra vagnar, vagn 1 kvinnovagn de första 150 s")
}
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)})
r.Party.nextEventAt = 1e9
r.NPCs = nil // folket ska inte stå i vägen för testboten
if !r.Party.HasMen() {
t.Fatal("tre i sällskapet: Du, Anna, Ville – Ville är man")
}
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.PathCanEnter))
waitForTrain(t, r)
if r.Time >= 150 {
t.Skip("tåget kom efter rusningen i det här seedet")
}
r.Step(frame, None, true)
cp := r.Mini.(*minigame.CarPick)
if !cp.Cars[0].WomenOnly {
t.Fatal("vagn 1 ska vara kvinnovagn nu")
}
// För tidigt: folk kliver av först.
r.StepMini(frame, minigame.Input{Confirm: true})
for cp.Alighting > 0 {
r.StepMini(frame, minigame.Input{})
}
// Välj kvinnovagnen med en man i sällskapet: grova minus.
for cp.Cursor != 0 {
r.StepMini(frame, minigame.Input{MoveX: -1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
if !r.WomenOnlyViolation || r.EarlyBoard != 1 || r.FlashKey != FlashWomenOnly || r.CarChosen == nil || r.CarChosen.Index != 1 {
t.Fatalf("violation=%v early=%d flash=%q car=%+v", r.WomenOnlyViolation, r.EarlyBoard, r.FlashKey, r.CarChosen)
}
if cr, ok := r.Mini.(*minigame.Crowd); ok { // full vagn -> trängsel
for i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
rd := r.Mini.(*minigame.Ride)
if rd.Crowd != r.CarChosen.Crowd || rd.StopSec != minigame.RideStopSec(rd.Crowd) {
t.Fatal("resan ska ärva vagnens trängsel")
}
ride(t, r)
if r.Outcome != Won {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
if r.Score.Penalty < PenaltyWomenOnly+PenaltyEarlyBoard {
t.Fatalf("avdrag=%d, vill ha minst %d", r.Score.Penalty, PenaltyWomenOnly+PenaltyEarlyBoard)
}
// Bonus för lugn vagn.
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(8)})
r2.Party.nextEventAt = 1e9
r2.NPCs = nil
walk(t, r2, l.Path(r2.Player.Tile, l.CorrectPlatform(), r2.PathCanEnter))
waitForTrain(t, r2)
r2.Step(frame, None, true)
ride(t, r2) // pickCar väljer lugnaste icke-kvinnovagnen
if r2.Outcome == Won && r2.CarChosen.Crowd == 0 && r2.Score.Car != ScoreCarBonus {
t.Fatalf("lugn vagn ska ge bonus: %+v", r2.Score)
}
if r2.WomenOnlyViolation || r2.Score.Penalty >= PenaltyWomenOnly {
t.Fatal("rätt vagn: inga grova minus")
}
}