All checks were successful
release / build-release (push) Successful in 1m37s
- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som multiplikatorer, Profile som följer med mellan banor och räknar ned - sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar tjänas av många steg, lång tid, tom matmätare och seedad otur vid kiosken; stats påverkar fart, mätartakter, läsning och hjälptid - egna kort (individualCards): Member.Card, spärren kräver allas saldo och drar från alla, automaten frågar vems kort, biljetter köps åt alla - sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag - level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser; ordboken utökad; generatorn är spec-driven - game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och förlustorsaker på resultatet, vyer för personval och vagn, skript-pick, flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet - plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning, roadmap, testplan - docs/README/wiki med sittplatsbilden Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
293 lines
8.1 KiB
Go
293 lines
8.1 KiB
Go
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
|
||
Card int // eget tågkort (yen) när banan kräver egna kort; spelaren använder plånboken
|
||
}
|
||
|
||
// MemberCardYen är vad följarnas egna kort har från start (lite – de får
|
||
// fyllas på).
|
||
const MemberCardYen = 500
|
||
|
||
// Rates skalar hur fort mätarna sjunker (debuffar/stats).
|
||
type Rates struct{ Thirst, Toilet float64 }
|
||
|
||
// 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
|
||
Rates Rates
|
||
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(), Rates: Rates{1, 1}}
|
||
for i := 0; i < n; i++ {
|
||
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
|
||
}
|
||
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*p.Rates.Thirst*dt)
|
||
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*p.Rates.Toilet*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
|
||
}
|
||
|
||
// Names är medlemmarnas namn i ordning.
|
||
func (p *Party) Names() []string {
|
||
out := make([]string, len(p.Members))
|
||
for i, m := range p.Members {
|
||
out[i] = m.Name
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AvgMeters är medelvärdet av allas lägsta mätare, 0..1 (poängen).
|
||
func (p *Party) AvgMeters() float64 {
|
||
sum := 0.0
|
||
for _, m := range p.Members {
|
||
sum += m.Meters.Min()
|
||
}
|
||
return sum / float64(len(p.Members)) / 100
|
||
}
|
||
|
||
// 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)
|
||
}
|