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

143
internal/sim/party_test.go Normal file
View File

@@ -0,0 +1,143 @@
package sim
import (
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
func TestMetersDecayAtRates(t *testing.T) {
p := NewParty(2, pt(1, 1), rng.FromInt(1))
for i := 0; i < 60*60; i++ { // 60 s
p.Advance(frame, float64(i)*frame)
}
m := p.Members[1].Meters
for _, c := range []struct {
name string
got float64
rate float64
}{{"mat", m.Food, FoodPerSec}, {"dryck", m.Drink, DrinkPerSec}, {"toa", m.Toilet, ToiletPerSec}} {
if want := 100 - c.rate*60; c.got > want+0.5 || c.got < want-0.5 {
t.Errorf("%s = %.1f, vill ha ~%.1f", c.name, c.got, want)
}
}
}
// TestMoreMembersMoreEvents: fler i lämmeltåget ger fler händelser.
func TestMoreMembersMoreEvents(t *testing.T) {
count := func(n int) int {
p := NewParty(n, pt(1, 1), rng.FromInt(3))
for i := 0; i < 60*600; i++ { // 10 min
p.Advance(frame, float64(i)*frame)
}
return p.Events
}
one, four := count(1), count(4)
if one < 8 || one > 20 {
t.Fatalf("ensam: %d händelser på 10 min (väntat ~13)", one)
}
if four < 3*one {
t.Fatalf("fyra i sällskapet (%d) ska ge klart fler händelser än en (%d)", four, one)
}
}
func TestEventsAreSeeded(t *testing.T) {
run := func(seed uint64) []string {
p := NewParty(3, pt(1, 1), rng.FromInt(seed))
for i := 0; i < 60*300; i++ {
p.Advance(frame, float64(i)*frame)
}
return p.Log
}
a, b := run(9), run(9)
if strings.Join(a, "|") != strings.Join(b, "|") {
t.Fatal("samma seed ska ge samma händelser")
}
if strings.Join(a, "|") == strings.Join(run(10), "|") {
t.Fatal("olika seeds ska ge olika händelser")
}
if len(a) == 0 {
t.Fatal("inga händelser på fem minuter")
}
}
func TestUrgesSlowAndFacilitiesRestore(t *testing.T) {
p := NewParty(3, pt(1, 1), rng.FromInt(1))
if p.SpeedFactor() != 1 {
t.Fatal("utan behov full fart")
}
anna, ville := p.Members[1], p.Members[2]
anna.setUrge(NeedToilet, 0)
anna.Meters.Toilet = 10
if p.SpeedFactor() != UrgeSpeedFactor || len(p.NeedsToilet()) != 1 {
t.Fatalf("toabehov: fart=%.2f behöver=%d", p.SpeedFactor(), len(p.NeedsToilet()))
}
if n := p.UseToilet(); n != 1 || anna.Urge != NoUrge || anna.Meters.Toilet != 100 || p.SpeedFactor() != 1 {
t.Fatalf("toaletten: n=%d urge=%v toa=%.0f", n, anna.Urge, anna.Meters.Toilet)
}
ville.setUrge(Thirsty, 0)
ville.Meters.Drink = 5
anna.Meters.Food = 40 // under RestoreBelow men utan behov – kiosken hjälper ändå
paid := 0
pay := func(yen int) bool { paid += yen; return true }
if n := p.BuyAtKiosk(pay); n != 2 || paid != 2*KioskPriceYen || ville.Urge != NoUrge || ville.Meters.Drink != 100 || anna.Meters.Food != 100 {
t.Fatalf("kiosken: n=%d betalt=%d", n, paid)
}
// Slut på pengar: ingen försörjs.
ville.Meters.Drink = 5
if n := p.BuyAtKiosk(func(int) bool { return false }); n != 0 || ville.Meters.Drink != 5 {
t.Fatal("utan pengar ska ingen få något")
}
ville.Meters.Drink = 100 // annars uppstår törsten igen när distraktionen går över
p.nextEventAt = 1e9 // inga slumphändelser i resten av testet
ville.setUrge(Distracted, 100)
if p.SpeedFactor() != DistractedSpeedFactor {
t.Fatal("distraktion halverar farten")
}
p.Advance(frame, 100+DistractionSec)
if ville.Urge != NoUrge {
t.Fatal("distraktionen ska gå över av sig själv")
}
ville.Meters.Food = 0
if p.SpeedFactor() != EmptySpeedFactor {
t.Fatal("tom mätare ger stor fartstraff")
}
}
func TestUrgeArisesBelowThreshold(t *testing.T) {
p := NewParty(1, pt(1, 1), rng.FromInt(1))
p.Members[0].Meters.Drink = UrgeThreshold - 1
p.Advance(frame, 0)
if p.Members[0].Urge != Thirsty {
t.Fatalf("urge=%v", p.Members[0].Urge)
}
}
func TestFollowersTrailLeader(t *testing.T) {
p := NewParty(3, pt(1, 1), rng.FromInt(1))
for _, tile := range []level.Point{pt(2, 1), pt(3, 1), pt(3, 2)} {
p.OnLeaderArrived(tile)
}
p.OnLeaderArrived(pt(3, 2)) // samma ruta igen: ingen dubblett
if from, to := p.FollowerTiles(1); from != pt(3, 1) || to != pt(3, 2) {
t.Fatalf("följare 1: %v -> %v", from, to)
}
if from, to := p.FollowerTiles(2); from != pt(2, 1) || to != pt(3, 1) {
t.Fatalf("följare 2: %v -> %v", from, to)
}
// Vid start står alla på startrutan.
q := NewParty(3, pt(5, 5), rng.FromInt(1))
if from, to := q.FollowerTiles(2); from != pt(5, 5) || to != pt(5, 5) {
t.Fatalf("start: %v -> %v", from, to)
}
}
func TestPartySizeIsClamped(t *testing.T) {
if NewParty(0, pt(0, 0), rng.FromInt(1)).Size() != 1 || NewParty(99, pt(0, 0), rng.FromInt(1)).Size() != len(PartyNames) {
t.Fatal("storleken ska klampas")
}
}