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