Files
metro-puzzle-jp/internal/sim/run_test.go
claude daa2089eda
All checks were successful
release / build-release (push) Successful in 1m49s
Inlärning (framsteg, stjärnor, ordlista) och M4: lämmeltåget
Inlärning enligt Björns regler:
- lang.Knowledge: framsteg per ord (3 läsningar = känt), språkpoäng,
  köp av valfria ord (kostnad = tier), Award per stjärna
- sim.Run: RunOptions med seed; stjärnor 1–3 mot par (parSec eller
  kortaste vägen × 0,5 s + 20 s); ≥2 stjärnor lär resmålets ord; att
  stanna på skylt/perrong ger seedad 35 % chans till framsteg per okänt ord
- game: ordlista (L) med kända ord, framsteg och kostnad; stjärnor, poäng
  och inlärt resmål på resultatskärmen; framstegsnotis; flaggor -points/-seed

M4 lämmeltåget:
- sim.Party: medlemmar med mat/dryck/toa-mätare, behov under tröskel,
  seedade händelser var 45 s / antal (toa, törst, hunger, distraktion),
  fartstraff, toalett (W, 3 s/person) och kiosk (K, 150 ¥/person),
  följare på ledarens spår
- level: rutor W och K, party och parSec i bandatan; banor med sällskap
  måste ha toalett och kiosk (testas); 001 har 2, 002 har 3 i sällskapet
- game: följare med färgton, behovsmarkering över huvudet, mätarpanel,
  facilitetsrutor med förlopp; genart ritar toalett och kiosk
- skriptläget: move stannar exakt på målrutan även när farten inte är
  bildruteexakt (sällskapets fartstraff), stall-skydd mot väggar
- docs/README/wiki/spelbeskrivning uppdaterade

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 21:29:09 +02:00

415 lines
13 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/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
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.
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
}
r.Step(frame, d, false)
}
if r.Player.Tile != next {
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 != Won || r.Boarded != tr {
t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded)
}
if r.GatePasses != 1 || r.Card.Balance != StartBalanceYen-l.FareYen {
t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Card.Balance, StartBalanceYen-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)
if r.Outcome != Lost || r.Boarded != tr || tr.Correct {
t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded)
}
}
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.Card.Balance = 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.Card.Balance = 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.Card.Balance = 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)
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.Card.Balance
r.Step(frame, None, true)
if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Card.Balance != before-KioskPriceYen || r.Served != 2 {
t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Card.Balance, 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)
}
}