M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
All checks were successful
release / build-release (push) Successful in 1m35s
All checks were successful
release / build-release (push) Successful in 1m35s
- level: Schedule (ankomst/intervall/uppehåll) med Status(t), spårnummer, avgift, skylttexter per i-ruta, Path (BFS) och UngatedPlatforms – regeln att perronger bara nås via spärrar testas för alla banor - sim.Run: klocka, tågkort (saldo dras vid spärr, tomt kort stoppas), ombordstigning med Enter när tåget är inne, vinst/förlust; headless bot-test klarar bana 001 och förlorar på fel tåg - game: perronginfo (avgår om / nästa tåg om), skyltruta, nekad spärr, tågvagn som ritas när tåget är inne, inforutan flyttas upp när spelaren står nära nederkanten; resultat med spärrpassager och saldo - bana 001 ombyggd så spärrarna är flaskhalsar; bana 002 omgenererad med spärrar runt perrongerna och skylttexter - tools/shots räknar ut vägar med BFS; skriptkommandot waittrain - docs/README/wiki uppdaterade med nya bilder Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
138
internal/sim/run.go
Normal file
138
internal/sim/run.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package sim
|
||||
|
||||
import "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
|
||||
// Outcome är hur en banomgång står sig.
|
||||
type Outcome int
|
||||
|
||||
// Utfall.
|
||||
const (
|
||||
Ongoing Outcome = iota
|
||||
Won // klev på rätt tåg
|
||||
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
|
||||
Time float64 // sekunder sedan banstart (styr tidtabellerna)
|
||||
Steps int
|
||||
GatePasses int
|
||||
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
|
||||
Outcome Outcome
|
||||
Boarded *level.Train
|
||||
}
|
||||
|
||||
// NewRun startar banan med spelaren på startrutan.
|
||||
func NewRun(l *level.Level, speed float64) *Run {
|
||||
return &Run{
|
||||
Level: l,
|
||||
Player: NewMover(l.SpawnPoint(), speed),
|
||||
Card: Card{Balance: StartBalanceYen},
|
||||
DeniedAt: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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) {
|
||||
r.DeniedAt = r.Time
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Run) inGate() bool {
|
||||
return r.Level.At(r.Player.Tile.X, r.Player.Tile.Y) == level.Gate
|
||||
}
|
||||
|
||||
// Step för banan framåt dt sekunder. want är hållen riktning, board=true
|
||||
// betyder att spelaren försöker kliva på tåget där hen står.
|
||||
func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
r.Time += dt
|
||||
before := r.Player.Tile
|
||||
wasGate := r.inGate()
|
||||
r.Steps += r.Player.Step(dt, want, r.CanEnter)
|
||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||
r.Card.Pay(r.Level.FareYen)
|
||||
r.GatePasses++
|
||||
}
|
||||
if board && !r.Player.Moving() {
|
||||
if tr, at, _ := r.PlatformHere(); tr != nil && at {
|
||||
r.Board(tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PlatformHere returnerar tåget vid perrongen spelaren står på (nil om
|
||||
// spelaren inte står på en perrong), om det är inne, och sekunder kvar
|
||||
// till avgång respektive ankomst.
|
||||
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
|
||||
t := r.Level.At(r.Player.Tile.X, r.Player.Tile.Y)
|
||||
if t != level.Platform && t != level.WrongPlatform {
|
||||
return nil, false, 0
|
||||
}
|
||||
tr = r.Level.TrainAt(t)
|
||||
if tr == nil {
|
||||
return nil, false, 0
|
||||
}
|
||||
atPlatform, seconds = tr.Schedule.Status(r.Time)
|
||||
return tr, atPlatform, seconds
|
||||
}
|
||||
|
||||
// SignHere returnerar skylten spelaren står stilla på, annars nil.
|
||||
func (r *Run) SignHere() *level.SignText {
|
||||
if r.Player.Moving() {
|
||||
return nil
|
||||
}
|
||||
return r.Level.SignAt(r.Player.Tile)
|
||||
}
|
||||
|
||||
// Board kliver på ett tåg och avgör banan.
|
||||
func (r *Run) Board(tr *level.Train) {
|
||||
r.Boarded = tr
|
||||
if tr.Correct {
|
||||
r.Outcome = Won
|
||||
} else {
|
||||
r.Outcome = Lost
|
||||
}
|
||||
}
|
||||
|
||||
// TrainVisible rapporterar om tåget för en perrongruta står inne just nu.
|
||||
func (r *Run) TrainVisible(t level.Tile) bool {
|
||||
tr := r.Level.TrainAt(t)
|
||||
if tr == nil {
|
||||
return false
|
||||
}
|
||||
at, _ := tr.Schedule.Status(r.Time)
|
||||
return at
|
||||
}
|
||||
182
internal/sim/run_test.go
Normal file
182
internal/sim/run_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
)
|
||||
|
||||
const frame = 1.0 / 60
|
||||
|
||||
func loadLevel(t *testing.T, id string) *level.Level {
|
||||
t.Helper()
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := level.LoadFile(filepath.Join(dir, id+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func dirTo(from, to level.Point) Dir {
|
||||
switch {
|
||||
case to.X > from.X:
|
||||
return Right
|
||||
case to.X < from.X:
|
||||
return Left
|
||||
case to.Y > from.Y:
|
||||
return Down
|
||||
default:
|
||||
return Up
|
||||
}
|
||||
}
|
||||
|
||||
// walk låter en bot följa en väg ruta för ruta, en bildruta i taget.
|
||||
func walk(t *testing.T, r *Run, path []level.Point) {
|
||||
t.Helper()
|
||||
for _, next := range path[1:] {
|
||||
d := dirTo(r.Player.Tile, next)
|
||||
for i := 0; i < 600; i++ {
|
||||
if r.Player.Tile == next && !r.Player.Moving() {
|
||||
break
|
||||
}
|
||||
r.Step(frame, d, false)
|
||||
}
|
||||
if r.Player.Tile != next {
|
||||
t.Fatalf("boten fastnade på %v på väg mot %v", r.Player.Tile, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForTrain står still tills tåget vid perrongen är inne.
|
||||
func waitForTrain(t *testing.T, r *Run) *level.Train {
|
||||
t.Helper()
|
||||
for i := 0; i < 60*120; i++ {
|
||||
tr, at, _ := r.PlatformHere()
|
||||
if tr == nil {
|
||||
t.Fatalf("står inte på en perrong: %v", r.Player.Tile)
|
||||
}
|
||||
if at {
|
||||
return tr
|
||||
}
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
t.Fatal("tåget kom aldrig")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBotWinsLevel001 är simuleringstestet för M2: en bot går kortaste vägen
|
||||
// från start till rätt perrong i bana 001, passerar spärren, väntar in tåget
|
||||
// och kliver på – helt utan fönster.
|
||||
func TestBotWinsLevel001(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.Platform)[0]
|
||||
path := l.Path(r.Player.Tile, goal, r.CanEnter)
|
||||
if path == nil {
|
||||
t.Fatal("ingen väg till perrongen")
|
||||
}
|
||||
walk(t, r, path)
|
||||
|
||||
if r.Outcome != Ongoing {
|
||||
t.Fatal("banan ska inte vara avgjord innan man kliver på")
|
||||
}
|
||||
r.Step(frame, None, true) // Enter medan tåget kanske inte är inne får inte avgöra fel
|
||||
tr := waitForTrain(t, r)
|
||||
if !tr.Correct {
|
||||
t.Fatal("rätt perrong ska ha rätt tåg")
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
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.Steps != len(path)-1 {
|
||||
t.Fatalf("steg=%d, vägen är %d rutor", r.Steps, len(path)-1)
|
||||
}
|
||||
// Efter utfall händer inget mer.
|
||||
before := r.Time
|
||||
r.Step(frame, Right, true)
|
||||
if r.Time != before {
|
||||
t.Fatal("Step ska vara en no-op när banan är avgjord")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLosesOnWrongTrain(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.WrongPlatform)[0]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateNeedsCardBalance(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
gate := l.Find(level.Gate)[0]
|
||||
r.Card.Balance = 0
|
||||
if r.CanEnter(gate) {
|
||||
t.Fatal("spärren ska stoppa ett tomt kort")
|
||||
}
|
||||
if r.DeniedAt < 0 {
|
||||
t.Fatal("nekandet ska registreras för HUD:en")
|
||||
}
|
||||
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
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardOnlyWhenTrainPresent(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
goal := l.Find(level.Platform)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
// Spola fram till en tidpunkt då tåget är borta.
|
||||
for {
|
||||
_, at, _ := r.PlatformHere()
|
||||
if !at {
|
||||
break
|
||||
}
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
r.Step(frame, None, true)
|
||||
if r.Outcome != Ongoing {
|
||||
t.Fatal("man ska inte kunna kliva på ett tåg som inte är inne")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignHereOnlyWhenStill(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, 6)
|
||||
sign := l.Find(level.Sign)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, sign, r.CanEnter))
|
||||
if sg := r.SignHere(); sg == nil || sg.Text.Get("sv") == "" {
|
||||
t.Fatal("stilla på skyltrutan ska ge skylten")
|
||||
}
|
||||
r.Step(frame, Left, false)
|
||||
if r.Player.Moving() && r.SignHere() != nil {
|
||||
t.Fatal("i rörelse visas ingen skylt")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user