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

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