M2: tåg med tidtabell, spärrar med kort, skyltar, vinst/förlust
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:
2026-10-23 19:36:45 +02:00
parent 3c00c7f356
commit a5a660b32f
26 changed files with 1031 additions and 135 deletions

View File

@@ -9,6 +9,7 @@ package level
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
@@ -55,23 +56,62 @@ func (t Text) Get(lang string) string {
return ""
}
// Schedule är ett tågs tidtabell i sekunder från banstart: första ankomst
// vid OffsetSec, därefter var IntervalSec; tåget står DwellSec vid perrongen.
type Schedule struct {
OffsetSec float64 `json:"offsetSec"`
IntervalSec float64 `json:"intervalSec"`
DwellSec float64 `json:"dwellSec"`
}
// Standardvärden som fylls i av Validate när fältet är 0.
const (
DefaultIntervalSec = 30.0
DefaultDwellSec = 10.0
DefaultFareYen = 200
)
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många
// sekunder det är kvar – till avgång om det står där, annars till ankomst.
func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
if t < s.OffsetSec {
return false, s.OffsetSec - t
}
phase := math.Mod(t-s.OffsetSec, s.IntervalSec)
if phase < s.DwellSec {
return true, s.DwellSec - phase
}
return false, s.IntervalSec - phase
}
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
type Train struct {
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
Track string `json:"track"` // spårnummer som visas på skyltar/HUD, t.ex. "2"
Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål
Schedule Schedule `json:"schedule"`
}
// SignText är texten på en skyltruta ('i').
type SignText struct {
X int `json:"x"`
Y int `json:"y"`
Text Text `json:"text"`
}
// Level är en bana.
type Level struct {
ID string `json:"id"`
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
Grid []string `json:"grid"`
Trains []Train `json:"trains"`
ID string `json:"id"`
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
Grid []string `json:"grid"`
Trains []Train `json:"trains"`
Signs []SignText `json:"signs"`
}
// Point är en rutkoordinat.
@@ -161,7 +201,8 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: saknar tåg", l.ID)
}
correct := 0
for i, tr := range l.Trains {
for i := range l.Trains {
tr := &l.Trains[i]
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
}
@@ -171,10 +212,87 @@ func (l *Level) Validate() error {
if tr.Correct {
correct++
}
sc := &tr.Schedule
if sc.IntervalSec == 0 {
sc.IntervalSec = DefaultIntervalSec
}
if sc.DwellSec == 0 {
sc.DwellSec = DefaultDwellSec
}
if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec {
return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc)
}
}
if correct == 0 {
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
}
if l.FareYen == 0 {
l.FareYen = DefaultFareYen
}
if l.FareYen < 0 {
return fmt.Errorf("level %s: negativ avgift", l.ID)
}
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
seen := map[Point]bool{}
for i, sg := range l.Signs {
p := Point{sg.X, sg.Y}
if l.At(p.X, p.Y) != Sign {
return fmt.Errorf("level %s: skylt %d på (%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y)
}
if seen[p] {
return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y)
}
if len(sg.Text) == 0 {
return fmt.Errorf("level %s: skylt på (%d,%d) saknar text", l.ID, p.X, p.Y)
}
seen[p] = true
}
for _, p := range l.Find(Sign) {
if !seen[p] {
return fmt.Errorf("level %s: skyltrutan (%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y)
}
}
return nil
}
// SignAt returnerar skylten på en ruta, eller nil.
func (l *Level) SignAt(p Point) *SignText {
for i := range l.Signs {
if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y {
return &l.Signs[i]
}
}
return nil
}
// Path returnerar kortaste vägen från from till to (båda inkluderade) via
// rutor som canEnter godkänner, eller nil om ingen väg finns.
func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point {
prev := map[Point]Point{from: from}
queue := []Point{from}
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
if p == to {
var path []Point
for ; p != from; p = prev[p] {
path = append(path, p)
}
path = append(path, from)
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
n := Point{p.X + d.X, p.Y + d.Y}
if _, ok := prev[n]; ok || !canEnter(n) {
continue
}
prev[n] = p
queue = append(queue, n)
}
}
return nil
}
@@ -215,6 +333,37 @@ func (l *Level) Solvable() bool {
return false
}
// UngatedPlatforms listar perronger som går att nå från start utan att
// passera någon spärr. Speldesignen kräver att alla perronger ligger
// innanför spärrar, så listan ska vara tom för skeppade banor.
func (l *Level) UngatedPlatforms() []Point {
start := l.SpawnPoint()
seen := map[Point]bool{start: true}
queue := []Point{start}
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
n := Point{p.X + d.X, p.Y + d.Y}
t := l.At(n.X, n.Y)
if seen[n] || !t.Passable() || t == Gate {
continue
}
seen[n] = true
queue = append(queue, n)
}
}
var out []Point
for _, t := range []Tile{Platform, WrongPlatform} {
for _, p := range l.Find(t) {
if seen[p] {
out = append(out, p)
}
}
}
return out
}
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
// inte går att nå – praktiskt som varning när man bygger banor.
func (l *Level) Unreachable() []Point {

View File

@@ -1,6 +1,7 @@
package level
import (
"math"
"os"
"path/filepath"
"sort"
@@ -69,15 +70,20 @@ func TestOutsideGridIsWall(t *testing.T) {
func TestValidationErrors(t *testing.T) {
cases := map[string]string{
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
"trasig json": "{",
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
"dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1),
"negativ offset": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"offsetSec": -1}`, 1),
"skylt utanför i-ruta": strings.Replace(sample, `"trains"`, `"signs": [{"x": 1, "y": 1, "text": {"sv": "x"}}], "trains"`, 1),
"i-ruta utan skylt": strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
"skylt utan text": strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {}}], "trains"`, 1),
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
"trasig json": "{",
}
for name, src := range cases {
if _, err := Parse([]byte(src)); err == nil {
@@ -133,6 +139,19 @@ func TestAllShippedLevels(t *testing.T) {
if u := l.Unreachable(); len(u) > 0 {
t.Errorf("onåbara interaktiva rutor: %v", u)
}
if u := l.UngatedPlatforms(); len(u) > 0 {
t.Errorf("perronger som nås utan att passera en spärr: %v – spärrarna måste vara flaskhalsar", u)
}
for i, tr := range l.Trains {
if tr.Track == "" {
t.Errorf("tåg %d saknar spårnummer", i)
}
}
for _, sg := range l.Signs {
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
}
}
})
}
}
@@ -169,3 +188,80 @@ func TestListAndLoadFile(t *testing.T) {
t.Fatal("saknad fil ska ge fel")
}
}
func TestScheduleStatus(t *testing.T) {
s := Schedule{OffsetSec: 5, IntervalSec: 30, DwellSec: 10}
cases := []struct {
t float64
at bool
secs float64
}{
{0, false, 5}, {5, true, 10}, {14.9, true, 0.1}, {15, false, 20}, {34.9, false, 0.1}, {35, true, 10}, {70, true, 5}, {80, false, 15},
}
for _, c := range cases {
at, secs := s.Status(c.t)
if at != c.at || math.Abs(secs-c.secs) > 1e-9 {
t.Errorf("t=%v: at=%v secs=%.2f, vill ha at=%v secs=%.2f", c.t, at, secs, c.at, c.secs)
}
}
}
func TestScheduleAndFareDefaults(t *testing.T) {
l := mustParse(t, sample)
sc := l.Trains[0].Schedule
if sc.IntervalSec != DefaultIntervalSec || sc.DwellSec != DefaultDwellSec || sc.OffsetSec != 0 {
t.Fatalf("standardtidtabell saknas: %+v", sc)
}
if l.FareYen != DefaultFareYen {
t.Fatalf("standardavgift saknas: %d", l.FareYen)
}
}
func TestSignAt(t *testing.T) {
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
`"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1)
l := mustParse(t, src)
if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" {
t.Fatalf("SignAt = %v", sg)
}
if l.SignAt(Point{1, 1}) != nil {
t.Fatal("ingen skylt på startrutan")
}
}
func TestPath(t *testing.T) {
l := mustParse(t, sample)
path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter)
// S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1)
if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) {
t.Fatalf("path = %v", path)
}
for i := 1; i < len(path); i++ {
dx, dy := path[i].X-path[i-1].X, path[i].Y-path[i-1].Y
if dx*dx+dy*dy != 1 {
t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i])
}
}
if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil {
t.Fatal("väg in i en vägg ska vara nil")
}
if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 {
t.Fatalf("väg till sig själv = %v", p)
}
}
func TestUngatedPlatforms(t *testing.T) {
// Perrong utan spärr framför -> rapporteras.
l := mustParse(t, sample)
if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) {
t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u)
}
// Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar.
l = mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.G.#"`, 1))
if u := l.UngatedPlatforms(); len(u) != 0 {
t.Fatalf("UngatedPlatforms = %v, vill ha inga", u)
}
if !l.Solvable() {
t.Fatal("banan ska vara lösbar med spärren passerbar")
}
}