M6: plånbok, biljetter, kortet i telefonen och seedade minispel
All checks were successful
release / build-release (push) Successful in 1m41s
All checks were successful
release / build-release (push) Successful in 1m41s
- sim.Wallet: IC-kort, kontanter, pappersbiljett, telefon med batteri, spenderat; följer med mellan banor (Game.Wallet) - internal/minigame: gemensamt Game-gränssnitt (Update/Done/Success), all slump från del-seeds: TopUp (hitta チャージ, belopp, mata sedlar), Ticket (taxetavla med resmål på japanska), Crowd (tidsfönster, två försök), Ride (stationerna passerar, kliv av vid resmålet) - sim.Run: spärren kräver biljett på biljettbanor annars användbart kort; telefonen ger ×1,1 fart men 15 % seedad omskanning (frysning 1,5 s), dött batteri gör kortet oanvändbart; automaten startar påfyllning eller biljettköp; tåget kontrollerar biljetten; fulla tåg kräver trängsel; resan avgör vinst/förlust (fel station, åkte förbi, fel tåg); StepMini låter klockan och sällskapet ticka medan rörelsen pausar - level: ticketRequired, rideStops, Train.Crowded; 002 är biljettbana med fullt tåg och fyra stopp; ordboken har stationer (kind) + sju nya namn - game: vyer för minispelen, plånbok i HUD, P i menyn = kort i telefonen, förlustorsaker och spenderat på resultatet, flaggor -phone/-card, skriptkommandon hold/waitstop; inforuta/minikarta döljs under minispel - plan: M7b (Björn) – tågvind, ankomstindikator nära rälsen, in-/utkörningsanimation för tåg och bussar – i spelbeskrivning, roadmap, testplan - docs/README/wiki med tre nya bilder (automat, biljettavla, tågresa) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
@@ -18,30 +19,12 @@ const (
|
||||
Lost // klev på fel tåg
|
||||
)
|
||||
|
||||
// Card är tågkortet (IC-kortet). Saldo i yen.
|
||||
type Card struct{ Balance int }
|
||||
|
||||
// CanPay rapporterar om saldot räcker.
|
||||
func (c *Card) CanPay(yen int) bool { return c.Balance >= yen }
|
||||
|
||||
// Pay drar beloppet om det går.
|
||||
func (c *Card) Pay(yen int) bool {
|
||||
if !c.CanPay(yen) {
|
||||
return false
|
||||
}
|
||||
c.Balance -= yen
|
||||
return true
|
||||
}
|
||||
|
||||
// StartBalanceYen är kortets saldo vid banstart tills M6 ger riktig ekonomi.
|
||||
const StartBalanceYen = 2000
|
||||
|
||||
// Run är tillståndet för en pågående bana: spelaren, klockan, kortet och
|
||||
// utfallet. Ren logik – LevelScene ritar den, testerna kör den headless.
|
||||
type Run struct {
|
||||
Level *level.Level
|
||||
Player *Mover
|
||||
Card Card
|
||||
Wallet *Wallet // pengar, kort, telefon, biljett – delas mellan banor
|
||||
Time float64 // sekunder sedan banstart (styr tidtabellerna)
|
||||
Steps int
|
||||
GatePasses int
|
||||
@@ -80,8 +63,51 @@ type Run struct {
|
||||
Served int // personer som fått mat/dryck/toa (statistik)
|
||||
Notice string // senaste meddelande från sällskapet (HUD)
|
||||
NoticeAt float64 // < 0 = aldrig
|
||||
|
||||
// Minispel, biljetter och telefon.
|
||||
Mini minigame.Game // pågående minispel (nil = inget); rörelsen pausar
|
||||
MiniKind MiniKind
|
||||
miniCount int
|
||||
PendingTrain *level.Train // tåget vi håller på att kliva på
|
||||
MissedTrains int
|
||||
TopUps int
|
||||
Rescans int
|
||||
RescanUntil float64 // telefonen måste skannas igen: spelaren står still tills dess
|
||||
LostReason string // varför banan förlorades (nyckel för UI)
|
||||
FlashKey string // kort meddelande till HUD (textnyckel)
|
||||
FlashAt float64 // < 0 = aldrig
|
||||
}
|
||||
|
||||
// MiniKind är vilket minispel som pågår.
|
||||
type MiniKind int
|
||||
|
||||
// Minispel.
|
||||
const (
|
||||
MiniNone MiniKind = iota
|
||||
MiniTopUp
|
||||
MiniTicket
|
||||
MiniCrowd
|
||||
MiniRide
|
||||
)
|
||||
|
||||
// Förlustorsaker (textnycklar).
|
||||
const (
|
||||
LostWrongTrain = "result.lost_wrong_train"
|
||||
LostWrongStop = "result.lost_wrong_stop"
|
||||
LostMissedStop = "result.lost_missed_stop"
|
||||
)
|
||||
|
||||
// Flash-nycklar.
|
||||
const (
|
||||
FlashWrongTicket = "hud.wrong_ticket"
|
||||
FlashMissedTrain = "hud.missed_train"
|
||||
FlashRescan = "hud.rescan"
|
||||
FlashNoTicket = "hud.need_ticket"
|
||||
)
|
||||
|
||||
// CrowdTries är antalet försök att få plats på ett fullt tåg.
|
||||
const CrowdTries = 2
|
||||
|
||||
// FacilityState är ett pågående toalettbesök (tar tid, avbryts om man går).
|
||||
type FacilityState struct {
|
||||
Active bool
|
||||
@@ -93,10 +119,11 @@ 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
|
||||
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
|
||||
Seed rng.Seed // omgångens seed (sparas med spelet)
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -128,10 +155,14 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
if o.Speed <= 0 {
|
||||
o.Speed = DefaultSpeed
|
||||
}
|
||||
if o.Wallet == nil {
|
||||
o.Wallet = NewWallet()
|
||||
}
|
||||
r := &Run{
|
||||
Level: l,
|
||||
Player: NewMover(l.SpawnPoint(), o.Speed),
|
||||
Card: Card{Balance: StartBalanceYen},
|
||||
Wallet: o.Wallet,
|
||||
FlashAt: -1,
|
||||
DeniedAt: -1,
|
||||
Dict: o.Dict,
|
||||
Know: o.Know,
|
||||
@@ -171,19 +202,27 @@ func (r *Run) StarsFor(t float64) int {
|
||||
}
|
||||
|
||||
// CanEnter avgör om spelaren får gå in på en ruta: inte vägg, och en spärr
|
||||
// kräver att kortet kan betala avgiften (att gå mellan två spärrutor är gratis).
|
||||
// kräver biljett (på biljettbanor) eller ett användbart kort med saldo (att
|
||||
// gå mellan två spärrutor är gratis).
|
||||
func (r *Run) CanEnter(p level.Point) bool {
|
||||
t := r.Level.At(p.X, p.Y)
|
||||
if !t.Passable() {
|
||||
return false
|
||||
}
|
||||
if t == level.Gate && !r.inGate() && !r.Card.CanPay(r.Level.FareYen) {
|
||||
if t == level.Gate && !r.inGate() && !r.canPassGate() {
|
||||
r.DeniedAt = r.Time
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Run) canPassGate() bool {
|
||||
if r.Level.TicketRequired {
|
||||
return r.Wallet.Ticket != ""
|
||||
}
|
||||
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen
|
||||
}
|
||||
|
||||
func (r *Run) inGate() bool {
|
||||
return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate
|
||||
}
|
||||
@@ -194,10 +233,20 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
if r.Mini != nil {
|
||||
return // minispel pågår: använd StepMini
|
||||
}
|
||||
r.Time += dt
|
||||
r.Wallet.Drain(dt)
|
||||
before := r.Player.Tile
|
||||
wasGate := r.inGate()
|
||||
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
|
||||
if r.Wallet.Phone && r.Wallet.Battery > 0 {
|
||||
r.Player.Speed *= PhoneSpeedFactor
|
||||
}
|
||||
if r.Time < r.RescanUntil {
|
||||
r.Player.Speed = 0 // står och skannar om
|
||||
}
|
||||
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||||
if r.Player.Tile != before {
|
||||
r.Party.OnLeaderArrived(r.Player.Tile)
|
||||
@@ -212,8 +261,15 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.stepFacility(dt)
|
||||
r.stepReading()
|
||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||
r.Card.Pay(r.Level.FareYen)
|
||||
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 {
|
||||
r.Rescans++
|
||||
r.RescanUntil = r.Time + RescanSec
|
||||
r.flash(FlashRescan)
|
||||
}
|
||||
}
|
||||
}
|
||||
if board && !r.Player.Moving() {
|
||||
switch r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) {
|
||||
@@ -221,14 +277,131 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.useToilet()
|
||||
case level.Kiosk:
|
||||
r.buyAtKiosk()
|
||||
case level.TicketMachine:
|
||||
r.startMachine()
|
||||
default:
|
||||
if tr, at, _ := r.PlatformHere(); tr != nil && at {
|
||||
r.Board(tr)
|
||||
r.tryBoard(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Run) flash(key string) { r.FlashKey, r.FlashAt = key, r.Time }
|
||||
|
||||
// Flashing rapporterar om ett kort meddelande ska visas just nu.
|
||||
func (r *Run) Flashing(within float64) bool { return r.FlashAt >= 0 && r.Time-r.FlashAt < within }
|
||||
|
||||
func (r *Run) miniSeed() rng.Seed {
|
||||
r.miniCount++
|
||||
return r.Seed.Derive(fmt.Sprintf("mini:%d", r.miniCount))
|
||||
}
|
||||
|
||||
// stationPool ger andra stationsnamn ur ordboken (seedat urval) till
|
||||
// taxetavlan och tågresan.
|
||||
func (r *Run) stationPool(exclude string, n int) []string {
|
||||
var pool []string
|
||||
for _, w := range r.Dict.OfKind("station") {
|
||||
if w != exclude {
|
||||
pool = append(pool, w)
|
||||
}
|
||||
}
|
||||
rr := r.Seed.Derive("pool:" + exclude).New()
|
||||
rr.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
if len(pool) > n {
|
||||
pool = pool[:n]
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// startMachine öppnar automaten: biljettköp på biljettbanor, annars påfyllning.
|
||||
func (r *Run) startMachine() {
|
||||
if r.Mini != nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
return
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp
|
||||
}
|
||||
|
||||
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
|
||||
// trängselminispelet, sedan börjar resan.
|
||||
func (r *Run) tryBoard(tr *level.Train) {
|
||||
if r.Mini != nil {
|
||||
return
|
||||
}
|
||||
if r.Level.TicketRequired && r.Wallet.Ticket != tr.Destination.Get("ja") {
|
||||
r.flash(FlashWrongTicket)
|
||||
return
|
||||
}
|
||||
r.PendingTrain = tr
|
||||
if tr.Crowded {
|
||||
r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd
|
||||
return
|
||||
}
|
||||
r.startRide()
|
||||
}
|
||||
|
||||
func (r *Run) startRide() {
|
||||
tr := r.PendingTrain
|
||||
if r.Level.TicketRequired {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// StepMini för ett pågående minispel framåt; klockan och sällskapet tickar
|
||||
// under tiden men spelaren står still.
|
||||
func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
if r.Outcome != Ongoing || r.Mini == nil {
|
||||
return
|
||||
}
|
||||
r.Time += dt
|
||||
r.Wallet.Drain(dt)
|
||||
r.Party.Advance(dt, r.Time)
|
||||
r.Mini.Update(in, dt)
|
||||
if !r.Mini.Done() {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
case *minigame.Crowd:
|
||||
if g.Success() {
|
||||
r.startRide()
|
||||
} else {
|
||||
r.MissedTrains++
|
||||
r.PendingTrain = nil
|
||||
r.flash(FlashMissedTrain)
|
||||
}
|
||||
case *minigame.Ride:
|
||||
tr := r.PendingTrain
|
||||
r.PendingTrain = nil
|
||||
switch {
|
||||
case g.Success():
|
||||
r.finish(tr, true, "")
|
||||
case !tr.Correct:
|
||||
r.finish(tr, false, LostWrongTrain)
|
||||
case g.GotOff >= 0:
|
||||
r.finish(tr, false, LostWrongStop)
|
||||
default:
|
||||
r.finish(tr, false, LostMissedStop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// useToilet startar ett toalettbesök för alla som behöver (tar tid).
|
||||
func (r *Run) useToilet() {
|
||||
if r.Facility.Active {
|
||||
@@ -260,7 +433,7 @@ func (r *Run) stepFacility(dt float64) {
|
||||
|
||||
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
|
||||
func (r *Run) buyAtKiosk() {
|
||||
r.Served += r.Party.BuyAtKiosk(r.Card.Pay)
|
||||
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
|
||||
}
|
||||
|
||||
// FacilityProgress är 0..1 för ett pågående toalettbesök.
|
||||
@@ -295,12 +468,13 @@ func (r *Run) SignHere() *level.SignText {
|
||||
return r.Level.SignAt(r.Player.Tile)
|
||||
}
|
||||
|
||||
// Board kliver på ett tåg och avgör banan. En vinst ger stjärnor efter tid,
|
||||
// språkpoäng, och vid tillräckligt bra resultat lär man sig resmålets ord.
|
||||
func (r *Run) Board(tr *level.Train) {
|
||||
// finish avgör banan. En vinst ger stjärnor efter tid, språkpoäng, och vid
|
||||
// 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
|
||||
if !tr.Correct {
|
||||
if !won {
|
||||
r.Outcome = Lost
|
||||
r.LostReason = reason
|
||||
return
|
||||
}
|
||||
r.Outcome = Won
|
||||
|
||||
@@ -7,10 +7,27 @@ import (
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
t.Helper()
|
||||
if r.MiniKind != MiniRide {
|
||||
t.Fatalf("ingen tågresa pågår (kind=%v)", r.MiniKind)
|
||||
}
|
||||
for i := 0; i < 60*120 && r.Mini != nil; i++ {
|
||||
g := r.Mini.(*minigame.Ride)
|
||||
r.StepMini(frame, minigame.Input{Confirm: g.Index == g.Dest})
|
||||
}
|
||||
if r.Mini != nil {
|
||||
t.Fatal("resan tog aldrig slut")
|
||||
}
|
||||
}
|
||||
|
||||
func loadDict(t *testing.T) *lang.Dictionary {
|
||||
t.Helper()
|
||||
b, err := res.ReadFile("text", "jp-dictionary.json")
|
||||
@@ -52,7 +69,9 @@ func dirTo(from, to level.Point) Dir {
|
||||
}
|
||||
}
|
||||
|
||||
// walk låter en bot följa en väg ruta för ruta, en bildruta i taget.
|
||||
// walk låter en bot följa en väg ruta för ruta, en bildruta i taget. Tangenten
|
||||
// släpps så fort steget mot nästa ruta påbörjats, så att boten stannar exakt
|
||||
// där även när farten inte är bildruteexakt (telefon- eller sällskapsfaktor).
|
||||
func walk(t *testing.T, r *Run, path []level.Point) {
|
||||
t.Helper()
|
||||
for _, next := range path[1:] {
|
||||
@@ -61,9 +80,13 @@ func walk(t *testing.T, r *Run, path []level.Point) {
|
||||
if r.Player.Tile == next && !r.Player.Moving() {
|
||||
break
|
||||
}
|
||||
r.Step(frame, d, false)
|
||||
if r.Player.Target == next || r.Player.Tile == next {
|
||||
r.Step(frame, None, false)
|
||||
} else {
|
||||
r.Step(frame, d, false)
|
||||
}
|
||||
}
|
||||
if r.Player.Tile != next {
|
||||
if r.Player.Tile != next || r.Player.Moving() {
|
||||
t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next)
|
||||
}
|
||||
}
|
||||
@@ -108,11 +131,15 @@ func TestBotWinsLevel001(t *testing.T) {
|
||||
t.Fatal("rätt perrong ska ha rätt tåg")
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Ongoing || r.MiniKind != MiniRide {
|
||||
t.Fatalf("att kliva på ska starta tågresan: outcome=%v kind=%v", r.Outcome, r.MiniKind)
|
||||
}
|
||||
ride(t, r)
|
||||
if r.Outcome != Won || r.Boarded != tr {
|
||||
t.Fatalf("outcome=%v boarded=%v", r.Outcome, r.Boarded)
|
||||
}
|
||||
if r.GatePasses != 1 || r.Card.Balance != StartBalanceYen-l.FareYen {
|
||||
t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Card.Balance, StartBalanceYen-l.FareYen)
|
||||
if r.GatePasses != 1 || r.Wallet.Card != StartCardYen-l.FareYen {
|
||||
t.Fatalf("spärrpassager=%d saldo=%d (vill ha 1 och %d)", r.GatePasses, r.Wallet.Card, StartCardYen-l.FareYen)
|
||||
}
|
||||
if r.Steps != len(path)-1 {
|
||||
t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1)
|
||||
@@ -135,8 +162,9 @@ func TestBotLosesOnWrongTrain(t *testing.T) {
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
tr := waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Lost || r.Boarded != tr || tr.Correct {
|
||||
t.Fatalf("outcome=%v boarded=%+v", r.Outcome, r.Boarded)
|
||||
ride(t, r)
|
||||
if r.Outcome != Lost || r.Boarded != tr || tr.Correct || r.LostReason != LostWrongTrain {
|
||||
t.Fatalf("outcome=%v boarded=%+v reason=%q", r.Outcome, r.Boarded, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +172,7 @@ func TestGateNeedsCardBalance(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||
gate := l.Find(level.Gate)[0]
|
||||
r.Card.Balance = 0
|
||||
r.Wallet.Card = 0
|
||||
if r.CanEnter(gate) {
|
||||
t.Fatal("spärren ska stoppa ett tomt kort")
|
||||
}
|
||||
@@ -154,13 +182,13 @@ func TestGateNeedsCardBalance(t *testing.T) {
|
||||
if l.Path(r.Player.Tile, l.Find(level.Platform)[0], r.CanEnter) != nil {
|
||||
t.Fatal("utan saldo ska perrongen vara onåbar")
|
||||
}
|
||||
r.Card.Balance = l.FareYen
|
||||
r.Wallet.Card = l.FareYen
|
||||
if !r.CanEnter(gate) {
|
||||
t.Fatal("med exakt avgiften ska spärren släppa igenom")
|
||||
}
|
||||
// Mellan två spärrutor är det gratis: ställ spelaren i spärren.
|
||||
r.Player.Tile, r.Player.Target = gate, gate
|
||||
r.Card.Balance = 0
|
||||
r.Wallet.Card = 0
|
||||
other := level.Point{X: gate.X + 1, Y: gate.Y}
|
||||
if l.At(other.X, other.Y) == level.Gate && !r.CanEnter(other) {
|
||||
t.Fatal("att gå mellan två spärrutor ska inte kosta")
|
||||
@@ -302,6 +330,7 @@ func TestGoodResultTeachesDestination(t *testing.T) {
|
||||
waitForTrain(t, r)
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
ride(t, r)
|
||||
if r.Outcome != Won || r.Stars != tc.stars {
|
||||
t.Fatalf("%s: outcome=%v stars=%d", tc.name, r.Outcome, r.Stars)
|
||||
}
|
||||
@@ -395,10 +424,10 @@ func TestToiletAndKioskInRun(t *testing.T) {
|
||||
walk(t, r, l.Path(r.Player.Tile, kiosk, r.CanEnter))
|
||||
anna.Meters.Drink = 5
|
||||
anna.Urge = Thirsty
|
||||
before := r.Card.Balance
|
||||
before := r.Wallet.Card
|
||||
r.Step(frame, None, true)
|
||||
if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Card.Balance != before-KioskPriceYen || r.Served != 2 {
|
||||
t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Card.Balance, r.Served)
|
||||
if anna.Meters.Drink != 100 || anna.Urge != NoUrge || r.Wallet.Card != before-KioskPriceYen || r.Served != 2 {
|
||||
t.Fatalf("kiosken: dryck=%.0f urge=%v saldo=%d->%d served=%d", anna.Meters.Drink, anna.Urge, before, r.Wallet.Card, r.Served)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,3 +441,226 @@ func TestPartySlowsLeader(t *testing.T) {
|
||||
t.Fatalf("fart=%.2f", r.Player.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRideWrongOrMissedStopLoses(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
getOff bool
|
||||
reason string
|
||||
}{{"fel station", true, LostWrongStop}, {"åker förbi", false, LostMissedStop}} {
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
|
||||
waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
g := r.Mini.(*minigame.Ride)
|
||||
if g.Dest < 1 {
|
||||
t.Fatalf("resmålet ska ligga på linjen men inte först: %d", g.Dest)
|
||||
}
|
||||
if tc.getOff {
|
||||
r.StepMini(frame, minigame.Input{Confirm: true}) // första stationen = fel
|
||||
} else {
|
||||
for i := 0; i < 60*120 && r.Mini != nil; i++ {
|
||||
r.StepMini(frame, minigame.Input{})
|
||||
}
|
||||
}
|
||||
if r.Outcome != Lost || r.LostReason != tc.reason {
|
||||
t.Fatalf("%s: outcome=%v reason=%q", tc.name, r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopUpFlowAtMachine(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||
r.Wallet.Card = 0
|
||||
gate := l.Find(level.Gate)[0]
|
||||
if r.CanEnter(gate) {
|
||||
t.Fatal("tomt kort stoppas")
|
||||
}
|
||||
machine := l.Find(level.TicketMachine)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
|
||||
r.Step(frame, None, true)
|
||||
g, ok := r.Mini.(*minigame.TopUp)
|
||||
if !ok || r.MiniKind != MiniTopUp {
|
||||
t.Fatalf("automaten ska starta påfyllning: %T", r.Mini)
|
||||
}
|
||||
cash := r.Wallet.Cash
|
||||
for g.Buttons[g.Cursor] != "チャージ" {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000)
|
||||
for i := 0; i < 600 && r.Mini != nil; i++ {
|
||||
r.StepMini(frame, minigame.Input{Hold: true})
|
||||
}
|
||||
if r.Mini != nil || r.Wallet.Card != 1000 || r.Wallet.Cash != cash-1000 || r.TopUps != 1 {
|
||||
t.Fatalf("efter påfyllning: card=%d cash=%d topups=%d", r.Wallet.Card, r.Wallet.Cash, r.TopUps)
|
||||
}
|
||||
if !r.CanEnter(gate) {
|
||||
t.Fatal("med påfyllt kort ska spärren släppa igenom")
|
||||
}
|
||||
// Minispelet pausar rörelsen men inte klockan.
|
||||
r.Step(frame, None, true)
|
||||
t0 := r.Time
|
||||
r.Step(frame, Right, false)
|
||||
if r.Mini == nil || r.Time != t0 || r.Player.Moving() {
|
||||
t.Fatal("under minispelet ska Step vara en no-op")
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Cancel: true})
|
||||
if r.Mini != nil || r.Time <= t0 {
|
||||
t.Fatal("Esc avbryter och klockan går")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTicketLevelFlow spelar bana 002: köp rätt biljett, passera spärren,
|
||||
// tränga sig på det fulla tåget och kliva av i Shibuya.
|
||||
func TestTicketLevelFlow(t *testing.T) {
|
||||
l := loadLevel(t, "002-shinjuku")
|
||||
if !l.TicketRequired || !l.Trains[0].Crowded {
|
||||
t.Fatal("002 ska kräva biljett och ha ett fullt tåg")
|
||||
}
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
gate := l.Find(level.Gate)[0]
|
||||
if r.CanEnter(gate) {
|
||||
t.Fatal("utan biljett stoppar spärren även med kort")
|
||||
}
|
||||
machine := l.Find(level.TicketMachine)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
|
||||
|
||||
// Fel biljett först: tåget vägrar.
|
||||
r.Step(frame, None, true)
|
||||
tk := r.Mini.(*minigame.Ticket)
|
||||
for tk.Options[tk.Cursor].Dest == "渋谷" {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
wrong := tk.Options[tk.Cursor]
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare {
|
||||
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
|
||||
}
|
||||
goal := l.CorrectPlatform()
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
if r.Mini != nil || r.FlashKey != FlashWrongTicket || r.Outcome != Ongoing {
|
||||
t.Fatal("fel biljett ska stoppas vid tåget")
|
||||
}
|
||||
|
||||
// Rätt biljett.
|
||||
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
|
||||
r.Step(frame, None, true)
|
||||
tk = r.Mini.(*minigame.Ticket)
|
||||
for tk.Options[tk.Cursor].Dest != "渋谷" {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
if r.Wallet.Ticket != "渋谷" {
|
||||
t.Fatalf("biljett=%q", r.Wallet.Ticket)
|
||||
}
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
cr, ok := r.Mini.(*minigame.Crowd)
|
||||
if !ok {
|
||||
t.Fatalf("fullt tåg ska ge trängselminispel, fick %T", r.Mini)
|
||||
}
|
||||
for i := 0; i < 600 && !cr.InZone(); i++ {
|
||||
r.StepMini(frame, minigame.Input{})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
if r.MiniKind != MiniRide || r.Wallet.Ticket != "" {
|
||||
t.Fatalf("efter trängseln: kind=%v biljett=%q", r.MiniKind, r.Wallet.Ticket)
|
||||
}
|
||||
ride(t, r)
|
||||
if r.Outcome != Won {
|
||||
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrowdFailureMissesTrain(t *testing.T) {
|
||||
l := loadLevel(t, "002-shinjuku")
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(11)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
r.Wallet.Ticket = "渋谷"
|
||||
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 n := 0; n < CrowdTries; n++ {
|
||||
for cr.InZone() {
|
||||
r.StepMini(frame, minigame.Input{})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
}
|
||||
if r.Mini != nil || r.MissedTrains != 1 || r.Outcome != Ongoing || r.FlashKey != FlashMissedTrain {
|
||||
t.Fatalf("missat tåg: mini=%v missed=%d outcome=%v", r.Mini, r.MissedTrains, r.Outcome)
|
||||
}
|
||||
if r.Wallet.Ticket != "渋谷" {
|
||||
t.Fatal("biljetten ska vara kvar när man inte kom med")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneSpeedRescanAndBattery(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
rescans := map[int]int{}
|
||||
for seed := uint64(1); seed <= 12; seed++ {
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(seed)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
r.Wallet.Phone = true
|
||||
r.Step(frame, Right, false)
|
||||
if math.Abs(r.Player.Speed-DefaultSpeed*PhoneSpeedFactor) > 1e-9 {
|
||||
t.Fatalf("telefonen ska ge fartbuff: %.2f", r.Player.Speed)
|
||||
}
|
||||
for r.Player.Moving() { // gå klart rutan innan vi väljer väg
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
gate := l.Find(level.Gate)[0]
|
||||
path := l.Path(r.Player.Tile, gate, r.CanEnter)
|
||||
for i, p := range path { // stanna på den första spärrutan – där sker skanningen
|
||||
if l.At(p.X, p.Y) == level.Gate {
|
||||
path = path[:i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
walk(t, r, path)
|
||||
rescans[r.Rescans]++
|
||||
if r.Rescans == 1 {
|
||||
if r.Time >= r.RescanUntil || r.FlashKey != FlashRescan {
|
||||
t.Fatal("omskanning ska frysa spelaren en stund")
|
||||
}
|
||||
before := r.Player.Tile
|
||||
r.Step(frame, Right, false)
|
||||
if r.Player.Moving() || r.Player.Tile != before {
|
||||
t.Fatal("under omskanningen står man still")
|
||||
}
|
||||
}
|
||||
}
|
||||
if rescans[1] == 0 || rescans[0] == 0 {
|
||||
t.Fatalf("på 12 seeds borde några men inte alla ge omskanning: %v", rescans)
|
||||
}
|
||||
// Determinism: samma seed -> samma utfall.
|
||||
run := func() int {
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
r.Wallet.Phone = true
|
||||
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Gate)[0], r.CanEnter))
|
||||
return r.Rescans
|
||||
}
|
||||
if run() != run() {
|
||||
t.Fatal("omskanningen ska vara seedad")
|
||||
}
|
||||
// Dött batteri: kortet i telefonen går inte -> spärren stoppar.
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
|
||||
r.Wallet.Phone = true
|
||||
r.Wallet.Battery = 0.001
|
||||
r.Step(frame, None, false)
|
||||
if r.Wallet.Battery != 0 || r.CanEnter(l.Find(level.Gate)[0]) {
|
||||
t.Fatal("dött batteri ska stoppa spärren")
|
||||
}
|
||||
if r.Player.Speed != DefaultSpeed {
|
||||
t.Fatal("ingen fartbuff med dött batteri")
|
||||
}
|
||||
}
|
||||
|
||||
67
internal/sim/wallet.go
Normal file
67
internal/sim/wallet.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Pengar och telefon.
|
||||
const (
|
||||
StartCardYen = 2000
|
||||
StartCashYen = 5000
|
||||
BatteryPerSec = 100.0 / 900 // telefonen håller 15 min – tar bara slut på långa banor
|
||||
PhoneSpeedFactor = 1.1 // kortet i telefonen: man slipper rota i plånboken
|
||||
PhoneRescanChance = 0.15 // ...men ibland måste man skanna igen
|
||||
RescanSec = 1.5
|
||||
)
|
||||
|
||||
// NewWallet ger startkassan med kortet i plånboken (inte telefonen).
|
||||
func NewWallet() *Wallet { return &Wallet{Card: StartCardYen, Cash: StartCashYen, Battery: 100} }
|
||||
|
||||
// CardUsable: kortet i telefonen går inte att använda med dött batteri.
|
||||
func (w *Wallet) CardUsable() bool { return !w.Phone || w.Battery > 0 }
|
||||
|
||||
// PayCard drar från kortet om det går.
|
||||
func (w *Wallet) PayCard(yen int) bool {
|
||||
if !w.CardUsable() || w.Card < yen {
|
||||
return false
|
||||
}
|
||||
w.Card -= yen
|
||||
w.Spent += yen
|
||||
return true
|
||||
}
|
||||
|
||||
// PayCash betalar kontant om det går.
|
||||
func (w *Wallet) PayCash(yen int) bool {
|
||||
if w.Cash < yen {
|
||||
return false
|
||||
}
|
||||
w.Cash -= yen
|
||||
w.Spent += yen
|
||||
return true
|
||||
}
|
||||
|
||||
// TopUp flyttar kontanter till kortet (påfyllning räknas inte som spenderat
|
||||
// förrän kortet används).
|
||||
func (w *Wallet) TopUp(yen int) bool {
|
||||
if w.Cash < yen {
|
||||
return false
|
||||
}
|
||||
w.Cash -= yen
|
||||
w.Card += yen
|
||||
return true
|
||||
}
|
||||
|
||||
// Drain laddar ur telefonen när kortet ligger i den.
|
||||
func (w *Wallet) Drain(dt float64) {
|
||||
if w.Phone {
|
||||
w.Battery = max(0, w.Battery-BatteryPerSec*dt)
|
||||
}
|
||||
}
|
||||
35
internal/sim/wallet_test.go
Normal file
35
internal/sim/wallet_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package sim
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWalletPayTopUpAndPhone(t *testing.T) {
|
||||
w := NewWallet()
|
||||
if w.Card != StartCardYen || w.Cash != StartCashYen || !w.CardUsable() {
|
||||
t.Fatal("startvärden")
|
||||
}
|
||||
if !w.PayCard(200) || w.Card != StartCardYen-200 || w.Spent != 200 {
|
||||
t.Fatal("PayCard")
|
||||
}
|
||||
if w.PayCard(99999) {
|
||||
t.Fatal("kan inte betala mer än saldot")
|
||||
}
|
||||
if !w.TopUp(1000) || w.Card != StartCardYen-200+1000 || w.Cash != StartCashYen-1000 || w.Spent != 200 {
|
||||
t.Fatal("TopUp flyttar kontanter till kortet utan att räknas som spenderat")
|
||||
}
|
||||
if w.TopUp(99999) || !w.PayCash(150) || w.PayCash(99999) {
|
||||
t.Fatal("kontantgränser")
|
||||
}
|
||||
w.Phone = true
|
||||
w.Drain(450) // halva batteriet
|
||||
if w.Battery < 49 || w.Battery > 51 || !w.CardUsable() {
|
||||
t.Fatalf("battery=%.1f", w.Battery)
|
||||
}
|
||||
w.Drain(10000)
|
||||
if w.Battery != 0 || w.CardUsable() || w.PayCard(1) {
|
||||
t.Fatal("dött batteri = kortet i telefonen går inte att använda")
|
||||
}
|
||||
w.Phone = false
|
||||
if !w.CardUsable() {
|
||||
t.Fatal("kortet i plånboken bryr sig inte om batteriet")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user