Files
metro-puzzle-jp/internal/sim/run_test.go
claude 8fca14e42d
All checks were successful
release / build-release (push) Successful in 1m34s
M9a: stationer i flera våningar med trappor
- level: Point har Z; banor kan ha floors (lika stora rutnät med namn),
  gamla grid-banor läses som en våning; trappor U/D på samma x,y en våning
  upp/ner valideras parvis; Neighbors ger grannar över våningar så att
  Path, lösbarhet, spärregel och Unreachable fungerar; skyltar har z
- sim: att kliva på en trappa flyttar spelaren till andra änden
  (FloorChanges); dimman är per våning och avslöjar trappans andra ände;
  tåg hörs/syns bara på egen våning
- game: bara spelarens våning ritas (följare på andra våningar döljs),
  HUD och minikarta visar våningens namn, trappgrafik i genart och färg på
  minikartan
- bana 004 Tokyo (svårighet 3, tre i sällskapet): start under mark på B1,
  trappa upp till 1F med perronger; generatorn gör våningar med trappor
  som flaskhalsar; ordboken utökad
- testbotar och tools/shots förstår trappor; två våningsbilder
- docs/README/wiki uppdaterade

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

1067 lines
36 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})
}
}
// 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).
// 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)
}
}
}
// 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) == 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) == 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) == 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")
}
}
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.CanEnter)
for i, p := range path {
if l.At(p) == level.Gate {
path = path[:i+1]
break
}
}
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.CanEnter)
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)
}
}