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,7 +12,8 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Input är vad spelaren gjorde den här bildrutan.
type Input struct {
Move int // -1 / +1 (upp/vänster resp. ner/höger), 0 = inget
Move int // -1 / +1 (upp resp. ner), 0 = inget
MoveX int // -1 / +1 (vänster resp. höger), 0 = inget
Confirm bool // Enter tryckt (just nu)
Hold bool // Enter hålls nere
Cancel bool // Esc tryckt

View File

@@ -11,13 +11,13 @@ import (
const frame = 1.0 / 60
func TestTopUpFlowAndDeterminism(t *testing.T) {
g := NewTopUp(rng.FromInt(1), 5000)
h := NewTopUp(rng.FromInt(1), 5000)
g := NewTopUp(rng.FromInt(1), 5000, nil)
h := NewTopUp(rng.FromInt(1), 5000, nil)
if strings.Join(g.Buttons, ",") != strings.Join(h.Buttons, ",") {
t.Fatal("samma seed ska ge samma knappordning")
}
if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000).Buttons, ",") &&
strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000).Buttons, ",") {
if strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(2), 5000, nil).Buttons, ",") &&
strings.Join(g.Buttons, ",") == strings.Join(NewTopUp(rng.FromInt(3), 5000, nil).Buttons, ",") {
t.Fatal("olika seeds ska (nästan alltid) ge olika ordning")
}
// Fel knapp: straff och frysning.
@@ -37,7 +37,7 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
}
// Rätt knapp -> belopp -> mata sedlar.
for g.Buttons[g.Cursor] != "チャージ" {
g.Update(Input{Move: 1}, frame)
g.Update(Input{MoveX: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Stage != TopUpPickAmount {
@@ -65,14 +65,14 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
t.Fatalf("done=%v success=%v amount=%d", g.Done(), g.Success(), g.Amount)
}
// Kontanter begränsar beloppen; utan pengar är spelet över direkt.
if a := NewTopUp(rng.FromInt(1), 2500).Amounts; len(a) != 2 || a[1] != 2000 {
if a := NewTopUp(rng.FromInt(1), 2500, nil).Amounts; len(a) != 2 || a[1] != 2000 {
t.Fatalf("amounts med 2500 kr: %v", a)
}
if !NewTopUp(rng.FromInt(1), 500).Done() {
if !NewTopUp(rng.FromInt(1), 500, nil).Done() {
t.Fatal("utan råd ska automaten vara klar direkt")
}
// Esc avbryter utan köp.
c := NewTopUp(rng.FromInt(1), 5000)
c := NewTopUp(rng.FromInt(1), 5000, nil)
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("avbrutet = klart utan framgång")
@@ -80,8 +80,8 @@ func TestTopUpFlowAndDeterminism(t *testing.T) {
}
func TestTicketBoardSeededAndAffordable(t *testing.T) {
g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000)
h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000)
g := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil)
h := NewTicket(rng.FromInt(4), "渋谷", []string{"東京", "上野", "新宿"}, 5000, nil)
if len(g.Options) != 4 {
t.Fatalf("%d alternativ", len(g.Options))
}
@@ -102,7 +102,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) {
t.Fatalf("köp: %+v", g.Chosen)
}
// Utan råd går det inte att köpa.
p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100)
p := NewTicket(rng.FromInt(4), "渋谷", []string{"東京"}, 100, nil)
p.Update(Input{Confirm: true}, frame)
if p.Done() {
t.Fatal("utan råd ska köpet inte gå igenom")
@@ -204,3 +204,92 @@ func TestRideWithoutFillersStillHasDestination(t *testing.T) {
t.Fatalf("stops=%v dest=%d", g.Stops, g.Dest)
}
}
func TestTopUpPicksMemberWithIndividualCards(t *testing.T) {
g := NewTopUp(rng.FromInt(1), 5000, []string{"Du", "Anna", "Ville"})
if g.Stage != TopUpPickMember {
t.Fatal("med egna kort väljs person först")
}
g.Update(Input{Move: 1}, frame)
g.Update(Input{Confirm: true}, frame)
if g.Member != 1 || g.Stage != TopUpPickButton || g.Cursor != 0 {
t.Fatalf("member=%d stage=%v", g.Member, g.Stage)
}
// 2×2-rutnätet: höger = +1, ner = +2.
g.Update(Input{MoveX: 1}, frame)
g.Update(Input{Move: 1}, frame)
if g.Cursor != 3 {
t.Fatalf("cursor=%d", g.Cursor)
}
if NewTopUp(rng.FromInt(1), 5000, []string{"Du"}).Stage != TopUpPickButton {
t.Fatal("ensam = inget personval")
}
}
func TestSeatSelectionPreferences(t *testing.T) {
members := []string{"Du", "Anna", "Ville", "Sara"}
g := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members)
h := NewTicket(rng.FromInt(9), "渋谷", []string{"東京", "上野"}, 5000, members)
if len(g.Prefs) != 3 || len(g.Assigned) != 4 {
t.Fatalf("prefs=%d assigned=%d", len(g.Prefs), len(g.Assigned))
}
for i := range g.Prefs {
if g.Prefs[i] != h.Prefs[i] {
t.Fatal("önskemålen ska vara seedade")
}
if g.Prefs[i].Kind == PrefWith && g.Prefs[i].With == g.Prefs[i].Member {
t.Fatal("ingen vill sitta bredvid sig själv")
}
}
// Köp resmålet -> sittplatssteget.
for g.Options[g.Cursor].Dest != "渋谷" {
g.Update(Input{Move: 1}, frame)
}
g.Update(Input{Confirm: true}, frame)
if g.Done() || g.Stage != TicketPickSeat || g.Chosen == nil {
t.Fatal("efter resmålet kommer sittplatserna")
}
// Placera alla på rad 0: plats 0,1,2,3 i ordning; upptagen plats går inte.
g.Update(Input{Confirm: true}, frame) // Du på (0,0)
g.Update(Input{Confirm: true}, frame) // upptagen: ignoreras
if g.Current != 1 {
t.Fatal("upptagen plats ska inte gå att välja")
}
for i := 1; i < 4; i++ {
g.Update(Input{MoveX: 1}, frame)
g.Update(Input{Confirm: true}, frame)
}
if !g.Done() || !g.Success() {
t.Fatal("alla placerade = köpet klart")
}
// Facit: räkna ut vilka som borde vara missnöjda med placeringen rad 0 / plats i.
var want []int
for _, p := range g.Prefs {
if !p.Satisfied(g.Assigned) {
want = append(want, p.Member)
}
}
if len(g.Unhappy) != len(want) {
t.Fatalf("unhappy=%v want=%v (prefs %+v)", g.Unhappy, want, g.Prefs)
}
// Egenskaperna.
if !(Seat{0, 0}).Window() || !(Seat{0, 3}).Window() || (Seat{0, 1}).Window() || !(Seat{0, 1}).Aisle() {
t.Fatal("fönster/gång")
}
if !(Seat{0, 0}).Left() || !(Seat{0, 2}).Right() || !(Seat{1, 0}).Beside(Seat{1, 1}) || (Seat{1, 1}).Beside(Seat{1, 2}) || (Seat{0, 0}).Beside(Seat{1, 0}) {
t.Fatal("sida/bredvid")
}
if (SeatPref{Member: 1, Kind: PrefWindow}).Satisfied([]Seat{{0, 0}, {-1, 0}}) {
t.Fatal("oplacerad = inte nöjd")
}
// Esc under sittplatsvalet avbryter köpet.
c := NewTicket(rng.FromInt(9), "渋谷", []string{"東京"}, 5000, members)
for c.Options[c.Cursor].Dest != "渋谷" {
c.Update(Input{Move: 1}, frame)
}
c.Update(Input{Confirm: true}, frame)
c.Update(Input{Cancel: true}, frame)
if !c.Done() || c.Success() {
t.Fatal("avbrutet sittplatsval = inget köp")
}
}

View File

@@ -4,13 +4,91 @@ import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// Ticket är biljettautomaten: en taxetavla med resmål (på japanska) i seedad
// ordning. Spelaren väljer ett resmål och betalar – om det var rätt avgör
// spärren/tåget senare.
// tåget senare. Med sittplatsval följer ett steg där sällskapet placeras i
// vagnen efter sina seedade önskemål.
type Ticket struct {
base
Options []TicketOption
Cursor int
Chosen *TicketOption
Cash int
// Sittplatser (nil Members = inget sittplatssteg).
Stage TicketStage
Members []string
Prefs []SeatPref // önskemål för medlemmar 1..n-1
Assigned []Seat // vald plats per medlem, Row -1 = ingen än
Current int // medlemmen som placeras just nu
Seat Seat // markören i vagnen
Unhappy []int // medlemmar vars önskemål inte uppfylldes (efter köpet)
}
// TicketStage är var i köpet man är.
type TicketStage int
// Steg.
const (
TicketPickDest TicketStage = iota
TicketPickSeat
)
// Vagnen: SeatRows rader med fyra platser – fönster V, gång V | gång H, fönster H.
const (
SeatRows = 3
SeatCols = 4
)
// Seat är en plats i vagnen.
type Seat struct{ Row, Col int }
// Window/Aisle/Left/Right-egenskaper för en plats.
func (s Seat) Window() bool { return s.Col == 0 || s.Col == 3 }
func (s Seat) Aisle() bool { return s.Col == 1 || s.Col == 2 }
func (s Seat) Left() bool { return s.Col <= 1 }
func (s Seat) Right() bool { return s.Col >= 2 }
// Beside: samma rad och samma platspar (0–1 eller 2–3), inte över gången.
func (s Seat) Beside(o Seat) bool { return s.Row == o.Row && s.Col/2 == o.Col/2 && s.Col != o.Col }
// PrefKind är ett slags önskemål.
type PrefKind int
// Önskemål.
const (
PrefWindow PrefKind = iota
PrefAisle
PrefLeft
PrefRight
PrefWith // bredvid en viss person
)
// SeatPref är en medlems önskemål.
type SeatPref struct {
Member int
Kind PrefKind
With int // medlemsindex vid PrefWith
}
// Satisfied kontrollerar önskemålet mot de valda platserna.
func (p SeatPref) Satisfied(assigned []Seat) bool {
s := assigned[p.Member]
if s.Row < 0 {
return false
}
switch p.Kind {
case PrefWindow:
return s.Window()
case PrefAisle:
return s.Aisle()
case PrefLeft:
return s.Left()
case PrefRight:
return s.Right()
case PrefWith:
o := assigned[p.With]
return o.Row >= 0 && s.Beside(o)
}
return true
}
// TicketOption är ett resmål på tavlan.
@@ -20,8 +98,9 @@ type TicketOption struct {
}
// NewTicket bygger tavlan: rätt resmål + lockande alternativ, blandade.
// Taxan är seedad per resmål (140–620 yen i steg om 10).
func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket {
// Taxan är seedad per resmål (140–620 yen i steg om 10). members (≥2)
// aktiverar sittplatsvalet med seedade önskemål för alla utom spelaren.
func NewTicket(seed rng.Seed, correct string, others []string, cash int, members []string) *Ticket {
r := newRand(seed, "ticket")
g := &Ticket{Cash: cash}
names := append([]string{correct}, others...)
@@ -29,25 +108,74 @@ func NewTicket(seed rng.Seed, correct string, others []string, cash int) *Ticket
g.Options = append(g.Options, TicketOption{Dest: n, Fare: 140 + 10*r.intn(49)})
}
r.shuffle(len(g.Options), func(i, j int) { g.Options[i], g.Options[j] = g.Options[j], g.Options[i] })
if len(members) >= 2 && len(members) <= SeatRows*SeatCols {
g.Members = append([]string(nil), members...)
for i := 1; i < len(members); i++ {
p := SeatPref{Member: i, Kind: PrefKind(r.intn(5))}
if p.Kind == PrefWith {
p.With = r.intn(len(members) - 1)
if p.With >= i {
p.With++ // aldrig sig själv
}
}
g.Prefs = append(g.Prefs, p)
}
g.Assigned = make([]Seat, len(members))
for i := range g.Assigned {
g.Assigned[i] = Seat{Row: -1}
}
}
return g
}
// Update flyttar markören och köper.
// SeatTaken rapporterar om platsen redan är vald.
func (g *Ticket) SeatTaken(s Seat) bool {
for _, a := range g.Assigned {
if a == s {
return true
}
}
return false
}
// Update flyttar markören, köper och placerar sällskapet.
func (g *Ticket) Update(in Input, dt float64) {
if g.done {
return
}
if in.Cancel {
g.done = true
g.done = true // avbrutet: inget köp
return
}
g.Cursor = wrap(g.Cursor+in.Move, len(g.Options))
if in.Confirm {
opt := g.Options[g.Cursor]
if opt.Fare > g.Cash {
return // har inte råd: välj annat eller avbryt
switch g.Stage {
case TicketPickDest:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Options))
if in.Confirm {
opt := g.Options[g.Cursor]
if opt.Fare > g.Cash {
return // har inte råd: välj annat eller avbryt
}
g.Chosen = &opt
if g.Members == nil {
g.done, g.success = true, true
return
}
g.Stage = TicketPickSeat
}
case TicketPickSeat:
g.Seat.Row = wrap(g.Seat.Row+in.Move, SeatRows)
g.Seat.Col = wrap(g.Seat.Col+in.MoveX, SeatCols)
if in.Confirm && !g.SeatTaken(g.Seat) {
g.Assigned[g.Current] = g.Seat
g.Current++
if g.Current >= len(g.Members) {
for _, p := range g.Prefs {
if !p.Satisfied(g.Assigned) {
g.Unhappy = append(g.Unhappy, p.Member)
}
}
g.done, g.success = true, true
}
}
g.Chosen = &opt
g.done, g.success = true, true
}
}

View File

@@ -2,11 +2,13 @@ package minigame
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
// TopUp är påfyllningsautomaten: hitta チャージ-knappen bland japanska
// knappar i seedad ordning, välj belopp, mata in sedlar genom att hålla
// Enter. Fel knapp kostar en straffsekund eller två.
// TopUp är påfyllningsautomaten: (vems kort, om alla har egna), hitta
// チャージ-knappen bland japanska knappar i seedad ordning, välj belopp, mata
// in sedlar genom att hålla Enter. Fel knapp kostar en straffsekund eller två.
type TopUp struct {
base
Members []string // namn när alla har egna kort (annars tom)
Member int // vald person (index i Members, 0 = spelaren)
Stage TopUpStage
Buttons []string // knapparnas text, seedad ordning
Cursor int
@@ -26,6 +28,7 @@ const (
TopUpPickButton TopUpStage = iota
TopUpPickAmount
TopUpFeed
TopUpPickMember // först, bara med egna kort
)
// Knapparna på en japansk biljettautomat. チャージ är den rätta.
@@ -40,10 +43,15 @@ const (
topUpDrainPerSec = 1.2
)
// NewTopUp skapar automaten. maxCash begränsar beloppen.
func NewTopUp(seed rng.Seed, maxCash int) *TopUp {
// NewTopUp skapar automaten. maxCash begränsar beloppen. members anges
// när alla i sällskapet har egna kort; då väljs person först.
func NewTopUp(seed rng.Seed, maxCash int, members []string) *TopUp {
r := newRand(seed, "topup")
g := &TopUp{Buttons: append([]string(nil), topUpButtons...), MaxCash: maxCash}
if len(members) > 1 {
g.Members = append([]string(nil), members...)
g.Stage = TopUpPickMember
}
r.shuffle(len(g.Buttons), func(i, j int) { g.Buttons[i], g.Buttons[j] = g.Buttons[j], g.Buttons[i] })
for _, a := range TopUpAmounts {
if a <= maxCash {
@@ -70,8 +78,14 @@ func (g *TopUp) Update(in Input, dt float64) {
return
}
switch g.Stage {
case TopUpPickMember:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Members))
if in.Confirm {
g.Member = g.Cursor
g.Stage, g.Cursor = TopUpPickButton, 0
}
case TopUpPickButton:
g.Cursor = wrap(g.Cursor+in.Move, len(g.Buttons))
g.Cursor = wrap(g.Cursor+2*in.Move+in.MoveX, len(g.Buttons)) // 2×2-rutnät
if in.Confirm {
if g.Buttons[g.Cursor] == topUpButtons[0] {
g.Stage, g.Cursor = TopUpPickAmount, 0