Inlärning (framsteg, stjärnor, ordlista) och M4: lämmeltåget
All checks were successful
release / build-release (push) Successful in 1m49s

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>
This commit is contained in:
2026-10-23 21:29:09 +02:00
parent b67493fddb
commit daa2089eda
38 changed files with 1436 additions and 115 deletions

View File

@@ -8,6 +8,7 @@ import (
"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 {
@@ -90,7 +91,7 @@ func waitForTrain(t *testing.T, r *Run) *level.Train {
// och kliver på – helt utan fönster.
func TestBotWinsLevel001(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, nil, nil)
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 {
@@ -116,6 +117,9 @@ func TestBotWinsLevel001(t *testing.T) {
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)
@@ -126,7 +130,7 @@ func TestBotWinsLevel001(t *testing.T) {
func TestBotLosesOnWrongTrain(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, nil, nil)
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)
@@ -138,7 +142,7 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
func TestGateNeedsCardBalance(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, nil, nil)
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
gate := l.Find(level.Gate)[0]
r.Card.Balance = 0
if r.CanEnter(gate) {
@@ -165,7 +169,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
func TestBoardOnlyWhenTrainPresent(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, nil, nil)
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.
@@ -184,7 +188,7 @@ func TestBoardOnlyWhenTrainPresent(t *testing.T) {
func TestSignHereOnlyWhenStill(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, nil, nil)
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") == "" {
@@ -201,7 +205,7 @@ func TestSignHereOnlyWhenStill(t *testing.T) {
func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
know := lang.NewKnowledge()
r := NewRun(l, 6, loadDict(t), know)
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))
@@ -241,7 +245,7 @@ func TestAskHelpRevealsSignAndTeachesWord(t *testing.T) {
func TestHelpIsCancelledByWalkingAway(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, loadDict(t), nil)
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()
@@ -256,8 +260,155 @@ func TestHelpIsCancelledByWalkingAway(t *testing.T) {
func TestNoHelpOffSign(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, 6, loadDict(t), nil)
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)
}
}