Files
metro-puzzle-jp/internal/sim/party.go
claude b2995d4ca9
All checks were successful
release / build-release (push) Successful in 1m37s
M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
- 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>
2026-10-24 10:18:17 +02:00

293 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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