M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
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>
This commit is contained in:
2026-10-24 10:18:17 +02:00
parent 9fc7e94074
commit b2995d4ca9
44 changed files with 1567 additions and 177 deletions

View File

@@ -76,8 +76,46 @@ type Run struct {
LostReason string // varför banan förlorades (nyckel för UI)
FlashKey string // kort meddelande till HUD (textnyckel)
FlashAt float64 // < 0 = aldrig
// Svårighet, poäng och debuffar (M7).
Stats Stats // multiplikatorer från profilens debuffar
Grumpy int // missnöjda med sittplatsen
poisoned bool // otur vid kiosken: matförgiftning efter banan
spentStart int // plånbokens Spent vid banstart
Score ScoreBreakdown
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
}
// ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct {
Time, Money, Meters, Explore, Penalty, Total int
}
// Poängformelns vikter.
const (
ScoreTimeMax = 500 // par eller snabbare
ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan
ScoreMetersMax = 200 // sällskapets mätare vid målgång
ScoreExploreMax = 100 // utforskad andel
PenaltyMissed = 50
PenaltyRescan = 10
PenaltyGrumpy = 30
GrumpyFoodPenalty = 25.0
)
// Fler förlustorsaker.
const (
LostTimeUp = "result.lost_time_up"
LostMissedTrains = "result.lost_missed_trains"
)
// Fler flash-nycklar.
const (
FlashGrumpy = "hud.grumpy"
FlashPoisoned = "hud.poisoned"
FlashNoCards = "hud.need_all_cards"
)
// MiniKind är vilket minispel som pågår.
type MiniKind int
@@ -119,11 +157,12 @@ type FacilityState struct {
// 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
Wallet *Wallet // nil = startkassa
Seed rng.Seed // omgångens seed (sparas med spelet)
Speed float64 // rutor per sekund (0 = DefaultSpeed)
Dict *lang.Dictionary // nil = tom ordbok
Know *lang.Knowledge // nil = ny kunskap
Wallet *Wallet // nil = startkassa
Debuffs []Debuff // aktiva debuffar från profilen
Seed rng.Seed // omgångens seed (sparas med spelet)
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
@@ -177,7 +216,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
baseSpeed: o.Speed,
NoticeAt: -1,
Fog: NewFog(l, FogRadius),
Stats: CombinedStats(o.Debuffs),
spentStart: o.Wallet.Spent,
}
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
r.Fog.Reveal(l, l.SpawnPoint())
if r.parSec <= 0 {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
@@ -218,9 +260,36 @@ func (r *Run) CanEnter(p level.Point) bool {
func (r *Run) canPassGate() bool {
if r.Level.TicketRequired {
return r.Wallet.Ticket != ""
if r.Wallet.Ticket == "" {
return false
}
return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size()
}
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen
if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen {
return false
}
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
return false
}
}
}
return true
}
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
func (r *Run) MembersWithoutFare() []*Member {
if !r.Level.IndividualCards {
return nil
}
var out []*Member
for _, m := range r.Party.Followers() {
if m.Card < r.Level.FareYen {
out = append(out, m)
}
}
return out
}
func (r *Run) inGate() bool {
@@ -240,7 +309,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.Wallet.Drain(dt)
before := r.Player.Tile
wasGate := r.inGate()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
if r.Wallet.Phone && r.Wallet.Battery > 0 {
r.Player.Speed *= PhoneSpeedFactor
}
@@ -260,11 +329,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
r.stepHelp(dt)
r.stepFacility(dt)
r.stepReading()
r.checkLimits()
if r.Player.Tile != before && r.inGate() && !wasGate {
r.GatePasses++
if !r.Level.TicketRequired {
r.Wallet.PayCard(r.Level.FareYen)
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance {
if r.Level.IndividualCards {
for _, m := range r.Party.Followers() {
m.Card -= r.Level.FareYen
r.Wallet.Spent += r.Level.FareYen
}
}
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] {
r.Rescans++
r.RescanUntil = r.Time + RescanSec
r.flash(FlashRescan)
@@ -321,10 +397,22 @@ func (r *Run) startMachine() {
}
if r.Level.TicketRequired {
dest := r.Level.Destination.Get("ja")
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket
var seats []string
if r.Level.SeatSelection {
seats = r.Party.Names()
}
cash := r.Wallet.Cash
if r.Level.IndividualCards {
cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person
}
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket
return
}
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp
var members []string
if r.Level.IndividualCards {
members = r.Party.Names()
}
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp
}
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
@@ -348,7 +436,7 @@ func (r *Run) tryBoard(tr *level.Train) {
func (r *Run) startRide() {
tr := r.PendingTrain
if r.Level.TicketRequired {
r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
}
dest := r.Level.Destination.Get("ja")
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide
@@ -365,18 +453,43 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
r.Party.Advance(dt, r.Time)
r.Mini.Update(in, dt)
if !r.Mini.Done() {
r.checkLimits()
return
}
m := r.Mini
r.Mini, r.MiniKind = nil, MiniNone
switch g := m.(type) {
case *minigame.TopUp:
if g.Success() && r.Wallet.TopUp(g.Amount) {
if !g.Success() {
break
}
if g.Member == 0 {
if r.Wallet.TopUp(g.Amount) {
r.TopUps++
}
} else if r.Wallet.Cash >= g.Amount {
r.Wallet.Cash -= g.Amount
r.Party.Members[g.Member].Card += g.Amount
r.TopUps++
}
case *minigame.Ticket:
if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) {
r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare
if !g.Success() || g.Chosen == nil {
break
}
count := 1
if r.Level.IndividualCards {
count = r.Party.Size()
}
if r.Wallet.PayCash(g.Chosen.Fare * count) {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count
}
if len(g.Unhappy) > 0 {
r.Grumpy += len(g.Unhappy)
for _, i := range g.Unhappy {
m := r.Party.Members[i]
m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty)
}
r.flash(FlashGrumpy)
}
case *minigame.Crowd:
if g.Success() {
@@ -432,8 +545,73 @@ func (r *Run) stepFacility(dt float64) {
}
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
// Med liten seedad otur blir maten dålig: matförgiftning efter banan.
func (r *Run) buyAtKiosk() {
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
n := r.Party.BuyAtKiosk(r.Wallet.PayCard)
if n == 0 {
return
}
r.Served += n
if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] {
r.poisoned = true
r.flash(FlashPoisoned)
}
}
// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras.
func (r *Run) checkLimits() {
if r.Outcome != Ongoing {
return
}
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
r.finish(nil, false, LostTimeUp)
return
}
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
r.finish(nil, false, LostMissedTrains)
}
}
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
func (r *Run) TimeLeft() float64 {
if r.Level.TimeLimitSec == 0 {
return 0
}
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
}
// computeScore fyller i poängen för en vunnen bana.
func (r *Run) computeScore() {
var sc ScoreBreakdown
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty)
r.Score = sc
}
// earnedDebuffs räknar ut vilka debuffar banan gav.
func (r *Run) earnedDebuffs() []Debuff {
var out []Debuff
if r.Steps >= SoreFeetSteps {
out = append(out, Debuff{SoreFeet, 1})
}
if r.Time >= TiredSec {
out = append(out, Debuff{Tired, 1})
}
for _, m := range r.Party.Members {
if m.Meters.Food <= 0 {
out = append(out, Debuff{Hangry, 1})
break
}
}
if r.poisoned {
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
}
return out
}
// FacilityProgress är 0..1 för ett pågående toalettbesök.
@@ -472,12 +650,14 @@ func (r *Run) SignHere() *level.SignText {
// tillräckligt bra resultat lär man sig resmålets ord.
func (r *Run) finish(tr *level.Train, won bool, reason string) {
r.Boarded = tr
r.NewDebuffs = r.earnedDebuffs()
if !won {
r.Outcome = Lost
r.LostReason = reason
return
}
r.Outcome = Won
r.computeScore()
r.Stars = r.StarsFor(r.Time)
r.PointsEarned = r.Know.Award(r.Stars)
if r.Stars >= lang.StarsToLearnDestination {
@@ -515,7 +695,7 @@ func (r *Run) stepReading() {
continue
}
seen[w] = true
if roll.Float64() >= lang.ReadChance {
if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] {
continue
}
if r.Know.AddProgress(w) {
@@ -547,7 +727,7 @@ func (r *Run) AskHelp() bool {
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
return false
}
secs := lang.HelpSeconds(r.Know.Level())
secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus]
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
return true
}