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

265
internal/sim/party.go Normal file
View File

@@ -0,0 +1,265 @@
package sim
import (
"fmt"
"math/rand/v2"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// Urge är ett behov som en i sällskapet kan få.
type Urge int
// Behov.
const (
NoUrge Urge = iota
NeedToilet // måste på toa -> toalettruta (W)
Thirsty // törstig -> kiosk (K)
Hungry // hungrig -> kiosk (K)
Distracted // distraherad av något – går över av sig självt
)
func (u Urge) String() string {
return [...]string{"", "toilet", "thirsty", "hungry", "distracted"}[u]
}
// Meters är mat, dryck och toa: 100 = allt väl, 0 = akut.
type Meters struct{ Food, Drink, Toilet float64 }
// Min är den lägsta mätaren.
func (m Meters) Min() float64 { return min(m.Food, m.Drink, m.Toilet) }
// Lowest returnerar behovet som hör till den lägsta mätaren.
func (m Meters) Lowest() (Urge, float64) {
u, v := Hungry, m.Food
if m.Drink < v {
u, v = Thirsty, m.Drink
}
if m.Toilet < v {
u, v = NeedToilet, m.Toilet
}
return u, v
}
// Member är en i sällskapet. Members[0] är spelaren själv.
type Member struct {
Name string
Meters Meters
Urge Urge
UrgeAt float64 // när behovet uppstod
}
// PartyNames är namnen i den ordning de följer med.
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
// Takter och trösklar. Mätarna går från 100 till 0 på ungefär 10/7/8 minuter.
const (
FoodPerSec = 100.0 / 600
DrinkPerSec = 100.0 / 420
ToiletPerSec = 100.0 / 480
UrgeThreshold = 25.0 // under detta uppstår ett behov av sig självt
BaseEventSec = 45.0 // händelseintervall för en ensam resenär; delas med antalet i sällskapet
DistractionSec = 4.0
UrgeSpeedFactor = 0.8 // någon har ett behov
DistractedSpeedFactor = 0.5
EmptySpeedFactor = 0.5 // någon mätare har nått 0
KioskPriceYen = 150
ToiletSecPerMember = 3.0
RestoreBelow = 50.0 // kiosk/toalett hjälper alla under den här nivån
)
// Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår.
type Party struct {
Members []*Member
trail []level.Point // ledarens rutor, senaste sist
rand *rand.Rand
nextEventAt float64
Events int // antal händelser som inträffat
Log []string // händelselogg (tester/HUD)
}
// NewParty skapar n medlemmar (minst 1) på startrutan. All slump i
// sällskapet kommer från seed.
func NewParty(n int, start level.Point, seed rng.Seed) *Party {
n = max(1, min(n, len(PartyNames)))
p := &Party{rand: seed.New()}
for i := 0; i < n; i++ {
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}})
}
p.trail = []level.Point{start}
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
return p
}
// Size är antalet i sällskapet inkl. spelaren.
func (p *Party) Size() int { return len(p.Members) }
// Followers är alla utom spelaren.
func (p *Party) Followers() []*Member { return p.Members[1:] }
// interval är tiden mellan händelser: fler i sällskapet = oftare.
func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
// Advance sänker mätarna, låter behov uppstå och kör de slumpade
// händelserna. t är omgångens klocka.
func (p *Party) Advance(dt, t float64) {
for _, m := range p.Members {
m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt)
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*dt)
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*dt)
if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec {
m.Urge = NoUrge
}
if m.Urge == NoUrge {
if u, v := m.Meters.Lowest(); v < UrgeThreshold {
m.setUrge(u, t)
p.log(t, m, "behovet uppstod av sig självt")
}
}
}
if t >= p.nextEventAt {
p.event(t)
p.nextEventAt = t + p.interval()*(0.7+0.6*p.rand.Float64())
}
}
// event: en slumpad i sällskapet får ett behov – oftast det som mätaren
// står lägst i, ibland bara en distraktion.
func (p *Party) event(t float64) {
p.Events++
m := p.Members[p.rand.IntN(len(p.Members))]
if m.Urge != NoUrge {
p.log(t, m, "händelse utan effekt (hade redan ett behov)")
return
}
if p.rand.Float64() < 0.25 {
m.setUrge(Distracted, t)
p.log(t, m, "blev distraherad")
return
}
u, _ := m.Meters.Lowest()
m.setUrge(u, t)
// Behovet är på riktigt: dra ned mätaren till tröskeln.
switch u {
case NeedToilet:
m.Meters.Toilet = min(m.Meters.Toilet, UrgeThreshold)
case Thirsty:
m.Meters.Drink = min(m.Meters.Drink, UrgeThreshold)
case Hungry:
m.Meters.Food = min(m.Meters.Food, UrgeThreshold)
}
p.log(t, m, "fick behovet "+u.String())
}
func (m *Member) setUrge(u Urge, t float64) { m.Urge, m.UrgeAt = u, t }
func (p *Party) log(t float64, m *Member, what string) {
p.Log = append(p.Log, fmt.Sprintf("%.1fs %s: %s", t, m.Name, what))
}
// Urges returnerar de som har ett aktivt behov.
func (p *Party) Urges() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge != NoUrge {
out = append(out, m)
}
}
return out
}
// SpeedFactor är hur mycket sällskapet saktar ned ledaren.
func (p *Party) SpeedFactor() float64 {
f := 1.0
for _, m := range p.Members {
switch {
case m.Meters.Min() <= 0:
f = min(f, EmptySpeedFactor)
case m.Urge == Distracted:
f = min(f, DistractedSpeedFactor)
case m.Urge != NoUrge:
f = min(f, UrgeSpeedFactor)
}
}
return f
}
// NeedsToilet är de som har nytta av en toalett just nu.
func (p *Party) NeedsToilet() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge == NeedToilet || m.Meters.Toilet < RestoreBelow {
out = append(out, m)
}
}
return out
}
// NeedsKiosk är de som har nytta av mat eller dryck just nu.
func (p *Party) NeedsKiosk() []*Member {
var out []*Member
for _, m := range p.Members {
if m.Urge == Thirsty || m.Urge == Hungry || m.Meters.Drink < RestoreBelow || m.Meters.Food < RestoreBelow {
out = append(out, m)
}
}
return out
}
// UseToilet fyller toamätaren för alla som behövde och tar bort behovet.
// Returnerar antalet som gick.
func (p *Party) UseToilet() int {
n := 0
for _, m := range p.NeedsToilet() {
m.Meters.Toilet = 100
if m.Urge == NeedToilet {
m.Urge = NoUrge
}
n++
}
return n
}
// BuyAtKiosk köper mat/dryck åt alla som behöver, en betalning per person
// via pay (returnerar false när pengarna är slut). Returnerar antalet
// försörjda.
func (p *Party) BuyAtKiosk(pay func(yen int) bool) int {
n := 0
for _, m := range p.NeedsKiosk() {
if !pay(KioskPriceYen) {
break
}
m.Meters.Food, m.Meters.Drink = 100, 100
if m.Urge == Thirsty || m.Urge == Hungry {
m.Urge = NoUrge
}
n++
}
return n
}
// OnLeaderArrived registrerar att ledaren nått en ny ruta; följarna följer
// spåret bakom.
func (p *Party) OnLeaderArrived(tile level.Point) {
if p.trail[len(p.trail)-1] == tile {
return
}
p.trail = append(p.trail, tile)
if keep := p.Size() + 1; len(p.trail) > keep {
p.trail = p.trail[len(p.trail)-keep:]
}
}
// FollowerTiles returnerar var följare i (1 = närmast ledaren) står och vart
// hen är på väg; följaren interpoleras med samma framsteg som ledaren.
func (p *Party) FollowerTiles(i int) (from, to level.Point) {
at := func(k int) level.Point {
if k < 0 {
return p.trail[0]
}
return p.trail[k]
}
n := len(p.trail)
return at(n - 1 - i), at(n - i)
}

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")
}
}

View File

@@ -1,8 +1,11 @@
package sim
import (
"fmt"
"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/rng"
)
// Outcome är hur en banomgång står sig.
@@ -53,8 +56,56 @@ type Run struct {
Revealed map[level.Point]bool // skyltar som översatts helt via hjälp
Learned string // senast inlärda ord (för HUD-notis)
LearnedAt float64 // när det lärdes in; < 0 = aldrig
// Slump och inlärning genom läsning.
Seed rng.Seed // all slump i omgången härleds härifrån
Visits map[level.Point]int // hur många gånger spelaren stannat på en skylt/perrong
lastTile level.Point
Progressed []string // ord som fick framsteg vid senaste läsningen, "ord n/3"
ProgressedAt float64 // < 0 = aldrig
// Resultat när banan vunnits.
Stars int // 1–3 efter tid mot par
PointsEarned int // språkpoäng
LearnedDest []string // resmålets ord som lärdes in tack vare bra resultat
parSec float64
// Sällskapet (lämmeltåget) och pågående toalettbesök.
Party *Party
Facility FacilityState
baseSpeed float64
Served int // personer som fått mat/dryck/toa (statistik)
Notice string // senaste meddelande från sällskapet (HUD)
NoticeAt float64 // < 0 = aldrig
}
// FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går).
type FacilityState struct {
Active bool
At level.Point
Remaining float64
Total float64
Count int
}
// RunOptions styr en banomgång.
type RunOptions struct {
Speed float64 // rutor per sekund (0 = DefaultSpeed)
Dict *lang.Dictionary // nil = tom ordbok
Know *lang.Knowledge // nil = ny kunskap
Seed rng.Seed // omgångens seed (sparas med spelet)
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
const DefaultSpeed = 6.0
// Par: utan parSec i bandatan räknas "bra tid" som kortaste vägen i rutor ×
// ParSecPerTile + ParSlackSec. Tre stjärnor under par, två under 1,5 × par.
const (
ParSecPerTile = 0.5
ParSlackSec = 20.0
)
// HelpState är en pågående "fråga någon om hjälp".
type HelpState struct {
Active bool
@@ -63,24 +114,54 @@ type HelpState struct {
Total float64
}
// NewRun startar banan med spelaren på startrutan. dict och know får vara
// nil (tom ordbok respektive ny kunskap).
func NewRun(l *level.Level, speed float64, dict *lang.Dictionary, know *lang.Knowledge) *Run {
if dict == nil {
dict = lang.NewDictionary()
// NewRun startar banan med spelaren på startrutan.
func NewRun(l *level.Level, o RunOptions) *Run {
if o.Dict == nil {
o.Dict = lang.NewDictionary()
}
if know == nil {
know = lang.NewKnowledge()
if o.Know == nil {
o.Know = lang.NewKnowledge()
}
return &Run{
Level: l,
Player: NewMover(l.SpawnPoint(), speed),
Card: Card{Balance: StartBalanceYen},
DeniedAt: -1,
Dict: dict,
Know: know,
Revealed: map[level.Point]bool{},
LearnedAt: -1,
if o.Speed <= 0 {
o.Speed = DefaultSpeed
}
r := &Run{
Level: l,
Player: NewMover(l.SpawnPoint(), o.Speed),
Card: Card{Balance: StartBalanceYen},
DeniedAt: -1,
Dict: o.Dict,
Know: o.Know,
Revealed: map[level.Point]bool{},
LearnedAt: -1,
Seed: o.Seed,
Visits: map[level.Point]int{},
lastTile: l.SpawnPoint(),
ProgressedAt: -1,
parSec: float64(l.ParSec),
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
baseSpeed: o.Speed,
NoticeAt: -1,
}
if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
}
return r
}
// ParSec är tiden för tre stjärnor.
func (r *Run) ParSec() float64 { return r.parSec }
// StarsFor ger 3 stjärnor under par, 2 under 1,5 × par, annars 1.
func (r *Run) StarsFor(t float64) int {
switch {
case t <= r.parSec:
return 3
case t <= 1.5*r.parSec:
return 2
default:
return 1
}
}
@@ -111,19 +192,79 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.Time += dt
before := r.Player.Tile
wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
r.Steps += r.Player.Step(dt, want, r.CanEnter)
if r.Player.Tile != before {
r.Party.OnLeaderArrived(r.Player.Tile)
}
logged := len(r.Party.Log)
r.Party.Advance(dt, r.Time)
if len(r.Party.Log) > logged {
r.Notice, r.NoticeAt = r.Party.Log[len(r.Party.Log)-1], r.Time
}
r.stepHelp(dt)
r.stepFacility(dt)
r.stepReading()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.Card.Pay(r.Level.FareYen)
r.GatePasses++
}
if board && !r.Player.Moving() {
if tr, at, _ := r.PlatformHere(); tr != nil && at {
r.Board(tr)
switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) {
case level.Toilet:
r.useToilet()
case level.Kiosk:
r.buyAtKiosk()
default:
if tr, at, _ := r.PlatformHere(); tr != nil && at {
r.Board(tr)
}
}
}
}
// useToilet startar ett toalettbesök för alla som behöver (tar tid).
func (r *Run) useToilet() {
if r.Facility.Active {
return
}
n := len(r.Party.NeedsToilet())
if n == 0 {
return
}
secs := ToiletSecPerMember * float64(n)
r.Facility = FacilityState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs, Count: n}
}
func (r *Run) stepFacility(dt float64) {
if !r.Facility.Active {
return
}
if r.Player.Moving() || r.Player.Tile != r.Facility.At {
r.Facility = FacilityState{}
return
}
r.Facility.Remaining -= dt
if r.Facility.Remaining > 0 {
return
}
r.Served += r.Party.UseToilet()
r.Facility = FacilityState{}
}
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
func (r *Run) buyAtKiosk() {
r.Served += r.Party.BuyAtKiosk(r.Card.Pay)
}
// FacilityProgress är 0..1 för ett pågående toalettbesök.
func (r *Run) FacilityProgress() float64 {
if !r.Facility.Active || r.Facility.Total <= 0 {
return 0
}
return 1 - r.Facility.Remaining/r.Facility.Total
}
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
// spelaren inte står på en perrong), om det är inne, och sekunder kvar
// till avgång respektive ankomst.
@@ -148,13 +289,63 @@ func (r *Run) SignHere() *level.SignText {
return r.Level.SignAt(r.Player.Tile)
}
// Board kliver på ett tåg och avgör banan.
// Board kliver på ett tåg och avgör banan. En vinst ger stjärnor efter tid,
// språkpoäng, och vid tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) Board(tr *level.Train) {
r.Boarded = tr
if tr.Correct {
r.Outcome = Won
} else {
if !tr.Correct {
r.Outcome = Lost
return
}
r.Outcome = Won
r.Stars = r.StarsFor(r.Time)
r.PointsEarned = r.Know.Award(r.Stars)
if r.Stars >= lang.StarsToLearnDestination {
for _, w := range r.Dict.WordsIn(r.Level.Destination.Get("ja")) {
if r.Know.Learn(w) {
r.LearnedDest = append(r.LearnedDest, w)
}
}
}
}
// stepReading: när spelaren stannar på en skylt eller perrong "läser" hen
// texten – varje okänt ord har chansen lang.ReadChance att ge ett framsteg,
// slumpat deterministiskt från seeden, skyltens position och besöksnumret.
func (r *Run) stepReading() {
if r.Player.Moving() || r.Player.Tile == r.lastTile {
return
}
r.lastTile = r.Player.Tile
text := ""
if sg := r.Level.SignAt(r.Player.Tile); sg != nil {
text = sg.Text.Get("ja")
} else if tr, _, _ := r.PlatformHere(); tr != nil {
text = tr.BoardText()
}
if text == "" {
return
}
r.Visits[r.Player.Tile]++
roll := r.Seed.Derive(fmt.Sprintf("read:%d,%d:%d", r.Player.Tile.X, r.Player.Tile.Y, r.Visits[r.Player.Tile])).New()
seen := map[string]bool{}
var progressed []string
for _, w := range r.Dict.WordsIn(text) {
if seen[w] || r.Know.Knows(w) {
continue
}
seen[w] = true
if roll.Float64() >= lang.ReadChance {
continue
}
if r.Know.AddProgress(w) {
r.Learned, r.LearnedAt = w, r.Time
} else {
progressed = append(progressed, fmt.Sprintf("%s %d/%d", w, r.Know.ProgressOf(w), lang.ProgressToLearn))
}
}
if len(progressed) > 0 {
r.Progressed, r.ProgressedAt = progressed, r.Time
}
}

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)
}
}