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

@@ -48,8 +48,16 @@ type Member struct {
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"}
@@ -72,6 +80,7 @@ const (
// 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
@@ -83,9 +92,9 @@ type Party struct {
// 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()}
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}})
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())
@@ -106,8 +115,8 @@ func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
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)
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
}
@@ -185,6 +194,24 @@ func (p *Party) SpeedFactor() float64 {
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

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
}

View File

@@ -12,6 +12,19 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// fillSeats placerar sällskapet på första lediga platser (sittplatssteget).
func fillSeats(r *Run, tk *minigame.Ticket) {
for r.Mini != nil && tk.Stage == minigame.TicketPickSeat {
for tk.SeatTaken(tk.Seat) {
r.StepMini(frame, minigame.Input{MoveX: 1})
if tk.Seat.Col == 0 {
r.StepMini(frame, minigame.Input{Move: 1})
}
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
}
// ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg
// finns inget resmål, så resan går till slutstationen.
func ride(t *testing.T, r *Run) {
@@ -487,7 +500,7 @@ func TestTopUpFlowAtMachine(t *testing.T) {
}
cash := r.Wallet.Cash
for g.Buttons[g.Cursor] != "チャージ" {
r.StepMini(frame, minigame.Input{Move: 1})
r.StepMini(frame, minigame.Input{MoveX: 1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000)
@@ -537,6 +550,7 @@ func TestTicketLevelFlow(t *testing.T) {
}
wrong := tk.Options[tk.Cursor]
r.StepMini(frame, minigame.Input{Confirm: true})
fillSeats(r, tk) // 002 har sittplatsval
if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare {
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
}
@@ -556,6 +570,7 @@ func TestTicketLevelFlow(t *testing.T) {
r.StepMini(frame, minigame.Input{Move: 1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
fillSeats(r, tk)
if r.Wallet.Ticket != "渋谷" {
t.Fatalf("biljett=%q", r.Wallet.Ticket)
}
@@ -664,3 +679,225 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
t.Fatal("ingen fartbuff med dött batteri")
}
}
func TestTimeLimitAndMissedTrainsLose(t *testing.T) {
l := loadLevel(t, "002-shinjuku")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
if r.TimeLeft() != float64(l.TimeLimitSec) {
t.Fatalf("TimeLeft=%.0f", r.TimeLeft())
}
r.Time = float64(l.TimeLimitSec) - 0.5
for i := 0; i < 60 && r.Outcome == Ongoing; i++ {
r.Step(frame, None, false)
}
if r.Outcome != Lost || r.LostReason != LostTimeUp || r.Boarded != nil {
t.Fatalf("tidsgräns: outcome=%v reason=%q", r.Outcome, r.LostReason)
}
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
r2.MissedTrains = l.MaxMissedTrains + 1
r2.Step(frame, None, false)
if r2.Outcome != Lost || r2.LostReason != LostMissedTrains {
t.Fatalf("missade tåg: outcome=%v reason=%q", r2.Outcome, r2.LostReason)
}
// Bana 001 har inga gränser.
r3 := NewRun(loadLevel(t, "001-flygplatsen"), RunOptions{Seed: rng.FromInt(1)})
r3.Time, r3.MissedTrains = 99999, 99
r3.Step(frame, None, false)
if r3.Outcome != Ongoing || r3.TimeLeft() != 0 {
t.Fatal("utan gränser förlorar man inte på tid eller missade tåg")
}
}
func TestScoreBreakdown(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
r.Party.nextEventAt = 1e9
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
ride(t, r)
sc := r.Score
if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty {
t.Fatalf("score=%+v", sc)
}
if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 {
t.Fatalf("snabb, billig vinst: %+v", sc)
}
if sc.Meters <= 0 || sc.Meters > ScoreMetersMax || sc.Explore <= 0 || sc.Explore > ScoreExploreMax {
t.Fatalf("mätare/utforskat: %+v", sc)
}
// Avdrag räknas.
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
r2.MissedTrains, r2.Rescans, r2.Grumpy = 1, 2, 1
r2.Time = 10 * r2.ParSec()
r2.computeScore()
if r2.Score.Penalty != PenaltyMissed+2*PenaltyRescan+PenaltyGrumpy || r2.Score.Time != ScoreTimeMax/10 {
t.Fatalf("avdrag: %+v", r2.Score)
}
}
func TestDebuffsAffectRunAndAreEarned(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Debuffs: []Debuff{{Blisters, 1}, {FoodPoisoning, 1}, {Hangry, 1}}})
r.Party.nextEventAt = 1e9
r.Step(frame, Right, false)
if math.Abs(r.Player.Speed-DefaultSpeed*0.8) > 1e-9 || r.Party.Rates.Toilet != 2 || r.Party.Rates.Thirst != 1.2 {
t.Fatalf("speed=%.2f rates=%+v", r.Player.Speed, r.Party.Rates)
}
r.Step(frame, None, false)
for r.Player.Moving() {
r.Step(frame, None, false)
}
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter))
r.AskHelp()
if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 {
t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want)
}
// Intjänade debuffar: många steg, lång tid, tom matmätare.
r.Steps, r.Time = SoreFeetSteps, TiredSec
r.Party.Members[1].Meters.Food = 0
r.finish(nil, false, LostTimeUp)
kinds := map[DebuffKind]bool{}
for _, d := range r.NewDebuffs {
kinds[d.Kind] = true
}
if !kinds[SoreFeet] || !kinds[Tired] || !kinds[Hangry] || kinds[FoodPoisoning] {
t.Fatalf("intjänade=%v", r.NewDebuffs)
}
}
func TestIndividualCardsAtGateAndMachine(t *testing.T) {
l := loadLevel(t, "003-shibuya")
if !l.IndividualCards || !l.TicketRequired || !l.SeatSelection || l.Party != 4 {
t.Fatal("003 ska ha egna kort, biljetter, sittplatser och fyra i sällskapet")
}
// Egna kort på en kortbana (utan biljettkrav): alla måste ha saldo.
l1 := loadLevel(t, "001-flygplatsen")
l1.IndividualCards = true
r := NewRun(l1, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
r.Party.nextEventAt = 1e9
gate := l1.Find(level.Gate)[0]
anna := r.Party.Members[1]
if anna.Card != MemberCardYen || !r.CanEnter(gate) {
t.Fatalf("Annas kort=%d canEnter=%v", anna.Card, r.CanEnter(gate))
}
anna.Card = 0
if r.CanEnter(gate) || len(r.MembersWithoutFare()) != 1 {
t.Fatal("utan saldo på Annas kort stoppar spärren alla")
}
// Fyll på Annas kort i automaten (personval först).
walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter))
r.Step(frame, None, true)
g := r.Mini.(*minigame.TopUp)
if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 {
t.Fatalf("stage=%v members=%v", g.Stage, g.Members)
}
r.StepMini(frame, minigame.Input{Move: 1})
r.StepMini(frame, minigame.Input{Confirm: true})
for g.Buttons[g.Cursor] != "チャージ" {
r.StepMini(frame, minigame.Input{MoveX: 1})
}
r.StepMini(frame, minigame.Input{Confirm: true})
r.StepMini(frame, minigame.Input{Confirm: true})
cash := r.Wallet.Cash
for i := 0; i < 600 && r.Mini != nil; i++ {
r.StepMini(frame, minigame.Input{Hold: true})
}
if anna.Card != 1000 || r.Wallet.Cash != cash-1000 || r.Wallet.Card != StartCardYen {
t.Fatalf("Annas kort=%d cash=%d eget=%d", anna.Card, r.Wallet.Cash, r.Wallet.Card)
}
// Spärren drar från allas kort.
before := r.Wallet.Spent
path := l1.Path(r.Player.Tile, gate, r.CanEnter)
for i, p := range path {
if l1.At(p.X, p.Y) == level.Gate {
path = path[:i+1]
break
}
}
walk(t, r, path)
if anna.Card != 1000-l1.FareYen || r.Wallet.Card != StartCardYen-l1.FareYen || r.Wallet.Spent != before+2*l1.FareYen {
t.Fatalf("efter spärren: anna=%d egen=%d spent=%d", anna.Card, r.Wallet.Card, r.Wallet.Spent)
}
}
// TestLevel003Flow: biljettbana med egna kort och sittplatser – alla
// behöver biljett, sällskapet placeras, och boten vinner.
func TestLevel003Flow(t *testing.T) {
l := loadLevel(t, "003-shibuya")
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)})
r.Party.nextEventAt = 1e9
machine := l.Find(level.TicketMachine)[0]
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
r.Step(frame, None, true)
tk := r.Mini.(*minigame.Ticket)
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
t.Fatalf("members=%v cash=%d", tk.Members, tk.Cash)
}
for tk.Options[tk.Cursor].Dest != "池袋" {
r.StepMini(frame, minigame.Input{Move: 1})
}
fare := tk.Options[tk.Cursor].Fare
r.StepMini(frame, minigame.Input{Confirm: true})
if tk.Stage != minigame.TicketPickSeat {
t.Fatal("sittplatsval ska följa")
}
// Placera alla så att önskemålen uppfylls när det går: prova platser tills nöjd.
for cur := 0; r.Mini != nil; cur++ {
best := minigame.Seat{Row: -1}
for row := 0; row < minigame.SeatRows && best.Row < 0; row++ {
for col := 0; col < minigame.SeatCols; col++ {
cand := minigame.Seat{Row: row, Col: col}
if tk.SeatTaken(cand) {
continue
}
trial := append([]minigame.Seat(nil), tk.Assigned...)
trial[cur] = cand
ok := true
for _, p := range tk.Prefs {
if p.Member == cur && !p.Satisfied(trial) {
ok = false
}
}
if ok {
best = cand
break
}
}
}
if best.Row < 0 {
t.Fatal("ingen ledig plats")
}
for tk.Seat != best {
in := minigame.Input{}
if tk.Seat.Row != best.Row {
in.Move = 1
} else {
in.MoveX = 1
}
r.StepMini(frame, in)
}
r.StepMini(frame, minigame.Input{Confirm: true})
}
if r.Wallet.TicketCount != 4 || r.Wallet.Ticket != "池袋" || r.Wallet.Cash != StartCashYen-4*fare {
t.Fatalf("biljetter=%d dest=%q cash=%d", r.Wallet.TicketCount, r.Wallet.Ticket, r.Wallet.Cash)
}
// Den giriga placeringen kan missa "bredvid X" som placeras senare; det
// är tillåtet men ska då synas som missnöje.
if r.Grumpy > 0 && r.FlashKey != FlashGrumpy {
t.Fatal("missnöje ska flashas")
}
goal := l.CorrectPlatform()
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
waitForTrain(t, r)
r.Step(frame, None, true)
cr := r.Mini.(*minigame.Crowd)
for i := 0; i < 600 && !cr.InZone(); i++ {
r.StepMini(frame, minigame.Input{})
}
r.StepMini(frame, minigame.Input{Confirm: true})
ride(t, r)
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
}
}

121
internal/sim/stats.go Normal file
View File

@@ -0,0 +1,121 @@
package sim
// Stats är spelarens multiplikatorer: 1,0 = normalt. Debuffar (och i M8
// items/skills) ändrar dem.
type Stats [5]float64
// Statindex.
const (
StatSpeed = iota // gångfart
StatThirst // hur fort dryckesmätaren sjunker (>1 = fortare)
StatFocus // chans att lära av skyltar, hjälptid (>1 = bättre)
StatToilet // hur fort toamätaren sjunker (>1 = fortare)
StatLuck // omskanning, trängsel (>1 = bättre; items i M8)
)
// DefaultStats är allt på 1,0.
func DefaultStats() Stats { return Stats{1, 1, 1, 1, 1} }
// DebuffKind är en typ av debuff (textnyckel: "debuff.<kind>").
type DebuffKind string
// Debuffar. De fyra första uppstår av spelandet (M7), resten delas ut av
// sidouppdrag (M10) och varar 1–4 banor.
const (
SoreFeet DebuffKind = "sore_feet" // många steg
Tired DebuffKind = "tired" // lång bana
Hangry DebuffKind = "hangry" // tom matmätare
FoodPoisoning DebuffKind = "food_poisoning" // otur vid kiosken
DOMS DebuffKind = "doms" // träningsvärk
Blisters DebuffKind = "blisters" // skoskav
HeavyBag DebuffKind = "heavy_bag" // köpt något tungt/stort
Stuffed DebuffKind = "stuffed" // proppmätt
Sweaty DebuffKind = "sweaty" // svettig och varm
)
// DebuffEffects är vad varje debuff gör med statsen.
var DebuffEffects = map[DebuffKind]Stats{
SoreFeet: {0.85, 1, 1, 1, 1},
Tired: {0.9, 1, 0.8, 1, 1},
Hangry: {1, 1.2, 0.7, 1, 1},
FoodPoisoning: {1, 1, 1, 2.0, 1},
DOMS: {0.9, 1, 1, 1, 1},
Blisters: {0.8, 1, 1, 1, 1},
HeavyBag: {0.9, 1.2, 1, 1, 1},
Stuffed: {0.9, 1, 1, 1.3, 1},
Sweaty: {1, 1.5, 0.9, 1, 1},
}
// Utlösare för de spelgenererade debuffarna.
const (
SoreFeetSteps = 150
TiredSec = 300.0
FoodPoisonChance = 0.05
FoodPoisonLevels = 2
)
// Debuff är en aktiv debuff med återstående antal banor.
type Debuff struct {
Kind DebuffKind `json:"kind"`
LevelsLeft int `json:"levelsLeft"`
}
// CombinedStats multiplicerar ihop effekterna av alla debuffar.
func CombinedStats(debuffs []Debuff) Stats {
st := DefaultStats()
for _, d := range debuffs {
if e, ok := DebuffEffects[d.Kind]; ok {
for i := range st {
st[i] *= e[i]
}
}
}
return st
}
// Profile är det som följer spelaren mellan banor och inte hör till
// kunskap eller plånbok: aktiva debuffar (items och skills i M8).
type Profile struct {
Debuffs []Debuff `json:"debuffs"`
}
// Add lägger till en debuff; finns den redan förlängs den till det längsta.
func (p *Profile) Add(kind DebuffKind, levels int) {
for i := range p.Debuffs {
if p.Debuffs[i].Kind == kind {
p.Debuffs[i].LevelsLeft = max(p.Debuffs[i].LevelsLeft, levels)
return
}
}
p.Debuffs = append(p.Debuffs, Debuff{Kind: kind, LevelsLeft: levels})
}
// Has rapporterar om debuffen är aktiv.
func (p *Profile) Has(kind DebuffKind) bool {
for _, d := range p.Debuffs {
if d.Kind == kind {
return true
}
}
return false
}
// EndLevel räknar ned alla debuffar en bana och tar bort de som gått ut.
// Returnerar de som försvann.
func (p *Profile) EndLevel() []DebuffKind {
var expired []DebuffKind
kept := p.Debuffs[:0]
for _, d := range p.Debuffs {
d.LevelsLeft--
if d.LevelsLeft <= 0 {
expired = append(expired, d.Kind)
continue
}
kept = append(kept, d)
}
p.Debuffs = kept
return expired
}
// Stats är de sammanlagda multiplikatorerna just nu.
func (p *Profile) Stats() Stats { return CombinedStats(p.Debuffs) }

View File

@@ -0,0 +1,45 @@
package sim
import "testing"
func TestDebuffEffectsAndProfile(t *testing.T) {
if DefaultStats() != (Stats{1, 1, 1, 1, 1}) {
t.Fatal("default")
}
for kind, e := range DebuffEffects {
changed := false
for i := range e {
if e[i] != 1 {
changed = true
}
}
if !changed {
t.Errorf("%s påverkar inget", kind)
}
}
st := CombinedStats([]Debuff{{SoreFeet, 1}, {Blisters, 1}})
if st[StatSpeed] < 0.67 || st[StatSpeed] > 0.69 || st[StatThirst] != 1 {
t.Fatalf("kombinerad fart %.3f", st[StatSpeed])
}
var p Profile
p.Add(Tired, 1)
p.Add(DOMS, 3)
p.Add(Tired, 2) // förlängs, inte dubbleras
if len(p.Debuffs) != 2 || !p.Has(Tired) || p.Debuffs[0].LevelsLeft != 2 {
t.Fatalf("debuffs=%v", p.Debuffs)
}
if p.Stats()[StatSpeed] != 0.9*0.9 {
t.Fatalf("stats=%v", p.Stats())
}
if exp := p.EndLevel(); len(exp) != 0 || len(p.Debuffs) != 2 {
t.Fatalf("efter bana 1: exp=%v debuffs=%v", exp, p.Debuffs)
}
if exp := p.EndLevel(); len(exp) != 1 || exp[0] != Tired || len(p.Debuffs) != 1 || p.Debuffs[0].Kind != DOMS {
t.Fatalf("efter bana 2: exp=%v debuffs=%v", exp, p.Debuffs)
}
p.EndLevel()
if len(p.Debuffs) != 0 || p.Has(DOMS) {
t.Fatal("DOMS ska ha gått ut efter tre banor")
}
}

View File

@@ -3,13 +3,14 @@ package sim
// Wallet är spelarens pengar och biljetter. Följer med mellan banor
// (sparas i M8).
type Wallet struct {
Card int `json:"card"` // IC-kortets saldo (yen)
Cash int `json:"cash"` // kontanter (yen)
Phone bool `json:"phone"` // kortet ligger i telefonen
Battery float64 `json:"battery"` // telefonens batteri 0..100
Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen
TicketFare int `json:"ticketFare"` // vad biljetten kostade
Spent int `json:"spent"` // totalt spenderat (poäng i M7)
Card int `json:"card"` // IC-kortets saldo (yen)
Cash int `json:"cash"` // kontanter (yen)
Phone bool `json:"phone"` // kortet ligger i telefonen
Battery float64 `json:"battery"` // telefonens batteri 0..100
Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen
TicketFare int `json:"ticketFare"` // vad biljetten kostade
TicketCount int `json:"ticketCount"` // antal biljetter (egna kort: en per person)
Spent int `json:"spent"` // totalt spenderat (poäng i M7)
}
// Pengar och telefon.