M9: navigeringslägen, linjenät och terminal
All checks were successful
release / build-release (push) Successful in 1m34s

internal/nav med resources/data/network.json (76 stationer, 11 linjer,
Yamanote som ringlinje). Tåg bedöms från lineId + terminus mot banans
station/to: rätt, fel håll, för långt bort (fler än 12 stationer) eller
ingen trafik; correct räknas ut för nätkopplade banor, 1-4 rätta tåg.
Tågresan visar linjens riktiga stationer. Nya förlustorsaker "Fel håll"
och "För långt bort". Listläge (nästa tåg nere till vänster) och
skyltläge (+50 % poäng) växlas med N på bankartan och sparas; -mode.
Terminalen: biljettautomaten visar linjetavlan på nätbanor. Perronger
P p Q q R r. Bana 006 Ikebukuro med fyra perronger. Banorna 001-005
kopplade till nätet. Skript: waitover och pick calm; poängraden
radbryts; skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:07:36 +02:00
parent de61568e44
commit f762f9554e
53 changed files with 3041 additions and 122 deletions

View File

@@ -14,6 +14,8 @@ import (
"path/filepath"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
)
// Tile är ett tecken i rutnätet.
@@ -24,8 +26,12 @@ const (
Wall Tile = '#' // vägg, ej passerbar
Floor Tile = '.' // golv
Spawn Tile = 'S' // startpunkt (exakt en)
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
WrongPlatform Tile = 'p' // perrong med fel tåg
Platform Tile = 'P' // perrong med ett tåg (vilket tåg som är rätt avgör tåget, inte tecknet)
WrongPlatform Tile = 'p' // ytterligare perrong (namnet är historiskt: i de första banorna stod fel tåg här)
Platform2 Tile = 'Q' // fler perronger för banor med flera giltiga vägar och fällor
Platform3 Tile = 'q'
Platform4 Tile = 'R'
Platform5 Tile = 'r'
Gate Tile = 'G' // spärr (kräver kort/biljett)
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
Sign Tile = 'i' // skylt
@@ -37,12 +43,26 @@ const (
LaneRight Tile = '>' // fil för gång neråt/åt höger
)
// PlatformTiles är alla perrongtecken, i den ordning tåg brukar listas.
var PlatformTiles = []Tile{Platform, WrongPlatform, Platform2, Platform3, Platform4, Platform5}
var knownTiles = map[Tile]bool{
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
Platform2: true, Platform3: true, Platform4: true, Platform5: true,
Gate: true, TicketMachine: true, Sign: true, Toilet: true, Kiosk: true,
StairsUp: true, StairsDown: true, LaneLeft: true, LaneRight: true,
}
// IsPlatform rapporterar om rutan är en perrong (något av PlatformTiles).
func (t Tile) IsPlatform() bool {
for _, p := range PlatformTiles {
if t == p {
return true
}
}
return false
}
// IsLane rapporterar om rutan är en markerad fil.
func (t Tile) IsLane() bool { return t == LaneLeft || t == LaneRight }
@@ -171,13 +191,17 @@ type Train struct {
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
Correct bool `json:"correct"` // tar spelaren till rätt resmål (räknas ut från nätet när banan har station/to)
LineID string `json:"lineId"` // linjen i linjenätet (resources/data/network.json)
Terminus string `json:"terminus"` // en station i färdriktningen (nätets id) – avgör riktningen
Crowded bool `json:"crowded"` // fullt tåg: minispel för att få plats (gäller alla vagnar)
Cars int `json:"cars"` // antal vagnar (0 = DefaultCars)
WomenOnlyCar int `json:"womenOnlyCar"` // 1-baserat vagnnummer för kvinnovagn, 0 = ingen
WomenOnlyFrom int `json:"womenOnlyFrom"` // kvinnovagn gäller från den här bantiden (s); 0 och To=0 = alltid
WomenOnlyTo int `json:"womenOnlyTo"` // …till den här bantiden (s)
Schedule Schedule `json:"schedule"`
Verdict nav.Verdict `json:"-"` // nätets bedömning (Unjudged när banan inte är kopplad)
}
// DefaultCars och gränser för vagnar.
@@ -232,6 +256,8 @@ type Level struct {
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
Station string `json:"station"` // stationen i linjenätet (nätets id); tomt = banan är inte kopplad till nätet
To string `json:"to"` // resmålets station i nätet
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
ParSec int `json:"parSec"` // "bra tid" för tre stjärnor (0 = räknas ut från kortaste vägen)
Party int `json:"party"` // antal i sällskapet inkl. spelaren (0 = 1)
@@ -245,6 +271,8 @@ type Level struct {
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
Trains []Train `json:"trains"`
Signs []SignText `json:"signs"`
Net *nav.Network `json:"-"` // linjenätet banan är kopplad till (nil om inte)
}
// Point är en rutkoordinat: x, y och våning z.
@@ -393,11 +421,12 @@ func (l *Level) Validate() error {
if !knownTiles[t] {
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d,%d)", l.ID, row[x], x, y, z)
}
if t.IsPlatform() {
platforms++
}
switch t {
case Spawn:
spawns++
case Platform, WrongPlatform:
platforms++
case StairsUp:
if l.FloorAt(z+1, x, y) != StairsDown {
return fmt.Errorf("level %s: trappa upp på (%d,%d,%d) saknar trappa ner ovanför", l.ID, x, y, z)
@@ -414,7 +443,7 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
}
if platforms == 0 {
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
return fmt.Errorf("level %s: saknar perronger (P/p/Q/q/R/r)", l.ID)
}
if len(l.Trains) == 0 {
return fmt.Errorf("level %s: saknar tåg", l.ID)
@@ -422,7 +451,7 @@ func (l *Level) Validate() error {
correct := 0
for i := range l.Trains {
tr := &l.Trains[i]
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
if len(tr.Platform) != 1 || !Tile(tr.Platform[0]).IsPlatform() {
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
}
if len(l.Find(Tile(tr.Platform[0]))) == 0 {
@@ -454,7 +483,7 @@ func (l *Level) Validate() error {
return fmt.Errorf("level %s: tåg %d: uppehållet måste vara ≥ %.1f s och tiden borta ≥ %.1f s för tågfaserna", l.ID, i, MinDwellSec, LeaveSec+ApproachSec+1)
}
}
if correct == 0 {
if correct == 0 && !l.Networked() {
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
}
if l.FareYen == 0 {
@@ -623,7 +652,7 @@ func (l *Level) UngatedPlatforms() []Point {
func (l *Level) Unreachable() []Point {
reach := l.Reachable()
var out []Point
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate, Toilet, Kiosk} {
for _, t := range append(append([]Tile(nil), PlatformTiles...), TicketMachine, Sign, Gate, Toilet, Kiosk) {
for _, p := range l.Find(t) {
if !reach[p] {
out = append(out, p)
@@ -677,28 +706,106 @@ func (l *Level) TrainAt(t Tile) *Train {
return nil
}
// LoadFile läser och validerar en bana från disk.
// LoadFile läser och validerar en bana från disk. Är banan kopplad till
// linjenätet läses nätet från NetworkPath bredvid banmappen.
func LoadFile(path string) (*Level, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("level: %w", err)
}
return Parse(b)
l, err := Parse(b)
if err != nil {
return nil, err
}
if !l.Networked() {
return l, nil
}
net, err := nav.LoadFile(NetworkPath(filepath.Dir(path)))
if err != nil {
return nil, fmt.Errorf("level %s: %w", l.ID, err)
}
if err := l.Annotate(net); err != nil {
return nil, err
}
return l, nil
}
// NetworkPath är linjenätets fil för en banmapp: ../network.json.
func NetworkPath(levelsDir string) string { return filepath.Join(levelsDir, "..", "network.json") }
// Networked rapporterar om banan pekar på linjenätet (station/to satta).
func (l *Level) Networked() bool { return l.Station != "" || l.To != "" }
// Annotate kopplar banan till linjenätet: varje tåg bedöms av nätet utifrån
// lineId och terminus, och correct räknas ut därifrån i stället för att
// skrivas i banfilen. Skyltläget tillåter 1–nav.MaxRoutes rätta tåg.
func (l *Level) Annotate(net *nav.Network) error {
if !l.Networked() {
return nil
}
if net == nil {
return fmt.Errorf("level %s: pekar på linjenätet men inget nät finns", l.ID)
}
if net.Station(l.Station) == nil || net.Station(l.To) == nil {
return fmt.Errorf("level %s: station %q eller resmål %q finns inte i nätet", l.ID, l.Station, l.To)
}
if want := net.StationName(l.To, "ja"); l.Destination.Get("ja") != want {
return fmt.Errorf("level %s: destination %q matchar inte nätets %q", l.ID, l.Destination.Get("ja"), want)
}
l.Net = net
correct := 0
for i := range l.Trains {
tr := &l.Trains[i]
ln := net.Line(tr.LineID)
if ln == nil {
return fmt.Errorf("level %s: tåg %d har okänd linje %q", l.ID, i, tr.LineID)
}
if tr.Line.Get("ja") != ln.Name["ja"] {
return fmt.Errorf("level %s: tåg %d heter %q men linjen %s heter %q", l.ID, i, tr.Line.Get("ja"), tr.LineID, ln.Name["ja"])
}
v := net.Judge(tr.LineID, l.Station, tr.Terminus, l.To)
if v == nav.Unjudged {
return fmt.Errorf("level %s: tåg %d (%s mot %q) går inte att bedöma i nätet", l.ID, i, tr.LineID, tr.Terminus)
}
tr.Verdict = v
tr.Correct = v == nav.Direct
if tr.Correct {
correct++
}
}
if correct < 1 || correct > nav.MaxRoutes {
return fmt.Errorf("level %s: %d rätta tåg, vill ha 1–%d", l.ID, correct, nav.MaxRoutes)
}
return nil
}
// IsSide rapporterar om banan är en sidobana.
func (l *Level) IsSide() bool { return l.Kind == "side" }
// LoadAll läser alla banor i en mapp, sorterade på id.
// LoadAll läser alla banor i en mapp, sorterade på id, och kopplar dem som
// pekar på linjenätet till ../network.json (läses en gång).
func LoadAll(dir string) ([]*Level, error) {
ids, err := List(dir)
if err != nil {
return nil, err
}
var net *nav.Network
if _, statErr := os.Stat(NetworkPath(dir)); statErr == nil {
if net, err = nav.LoadFile(NetworkPath(dir)); err != nil {
return nil, err
}
}
var out []*Level
for _, id := range ids {
l, err := LoadFile(filepath.Join(dir, id+".json"))
b, err := os.ReadFile(filepath.Join(dir, id+".json"))
if err != nil {
return nil, fmt.Errorf("level: %w", err)
}
l, err := Parse(b)
if err != nil {
return nil, err
}
if err := l.Annotate(net); err != nil {
return nil, err
}
out = append(out, l)

View File

@@ -2,12 +2,12 @@ package level
import (
"math"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
@@ -122,11 +122,7 @@ func TestAllShippedLevels(t *testing.T) {
}
for _, f := range files {
t.Run(filepath.Base(f), func(t *testing.T) {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
l, err := Parse(b)
l, err := LoadFile(f) // kopplar nätbanor till network.json (rätt tåg räknas ut där)
if err != nil {
t.Fatal(err)
}
@@ -146,6 +142,18 @@ func TestAllShippedLevels(t *testing.T) {
if tr.Track == "" {
t.Errorf("tåg %d saknar spårnummer", i)
}
if l.Networked() && tr.Verdict == nav.Unjudged {
t.Errorf("tåg %d är inte bedömt av nätet", i)
}
}
if l.Networked() && len(l.Trains) > 1 {
trap := false
for _, tr := range l.Trains {
trap = trap || !tr.Correct
}
if !trap {
t.Error("nätbanor med flera tåg ska ha minst ett fel tåg att undvika")
}
}
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
@@ -256,6 +264,112 @@ func TestPath(t *testing.T) {
}
}
// TestAnnotateWithNetwork: rätt/fel tåg räknas ut från linjenätet, inklusive
// fel håll och för långt bort, och orimliga kopplingar ger fel.
func TestAnnotateWithNetwork(t *testing.T) {
net, err := nav.Parse([]byte(`{"stations":[
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},{"id":"d","name":{"ja":"え"}},{"id":"x","name":{"ja":"さ"}}],
"lines":[{"id":"l1","name":{"ja":"一線"},"stations":["a","b","c","d"]},{"id":"l2","name":{"ja":"二線"},"stations":["x","b"]}]}`))
if err != nil {
t.Fatal(err)
}
src := `{"id":"net","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"station":"b","to":"c",
"grid":["#########","#S.GP.Gp#","#..GQ.Gq#","#########"],
"trains":[
{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"},
{"platform":"p","track":"2","line":{"ja":"一線"},"destination":{"ja":"あ"},"lineId":"l1","terminus":"a"},
{"platform":"Q","track":"3","line":{"ja":"二線"},"destination":{"ja":"さ"},"lineId":"l2","terminus":"x"},
{"platform":"q","track":"4","line":{"ja":"一線"},"destination":{"ja":"え"},"lineId":"l1","terminus":"d"}]}`
l, err := Parse([]byte(src))
if err != nil {
t.Fatal(err)
}
if !l.Networked() || l.Net != nil {
t.Fatal("nätbana före Annotate")
}
if err := l.Annotate(net); err != nil {
t.Fatal(err)
}
want := []nav.Verdict{nav.Direct, nav.WrongDirection, nav.NoService, nav.Direct}
for i, tr := range l.Trains {
if tr.Verdict != want[i] || tr.Correct != (want[i] == nav.Direct) {
t.Errorf("tåg %d: verdict=%v correct=%v, vill ha %v", i, tr.Verdict, tr.Correct, want[i])
}
}
if l.Net != net || !l.Solvable() || l.CorrectPlatform() != (Point{X: 4, Y: 1}) {
t.Fatal("kopplad bana ska vara lösbar via P")
}
// Fel: okänd linje, fel linjenamn, resmål som inte matchar, inget rätt tåg, nät saknas.
cases := map[string]string{
"okänd linje": strings.Replace(src, `"lineId":"l1","terminus":"d"}`, `"lineId":"nope","terminus":"d"}`, 1),
"fel linjenamn": strings.Replace(src, `"line":{"ja":"二線"}`, `"line":{"ja":"一線"}`, 1),
"fel resmålstext": strings.Replace(src, `"destination":{"ja":"う"},"station"`, `"destination":{"ja":"え"},"station"`, 1),
"inget rätt tåg": strings.Replace(src, `"to":"c"`, `"to":"a"`, 1),
"obedömbart tåg": strings.Replace(src, `"lineId":"l1","terminus":"a"}`, `"lineId":"l1","terminus":"b"}`, 1),
}
for name, c := range cases {
l, err := Parse([]byte(c))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if err := l.Annotate(net); err == nil {
t.Errorf("%s: skulle ha gett fel", name)
}
}
if err := l.Annotate(nil); err == nil {
t.Error("nätbana utan nät ska ge fel")
}
// Banor utan station/to påverkas inte.
plain, err := Parse([]byte(`{"id":"p","name":{"sv":"x"},"difficulty":1,"destination":{"ja":"う"},"grid":["#####","#S.P#","#####"],
"trains":[{"platform":"P","track":"1","line":{"ja":"一線"},"destination":{"ja":"う"},"correct":true}]}`))
if err != nil {
t.Fatal(err)
}
if err := plain.Annotate(net); err != nil || plain.Net != nil || plain.Trains[0].Verdict != nav.Unjudged || !plain.Trains[0].Correct {
t.Fatal("okopplad bana ska lämnas i fred")
}
}
func TestPlatformTilesAndShippedVerdicts(t *testing.T) {
for _, tl := range PlatformTiles {
if !tl.IsPlatform() || !knownTiles[tl] {
t.Errorf("%c ska vara en känd perrong", tl)
}
}
if Gate.IsPlatform() || Floor.IsPlatform() {
t.Fatal("spärr/golv är inte perronger")
}
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
l, err := LoadFile(filepath.Join(dir, "005-ueno.json"))
if err != nil {
t.Fatal(err)
}
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Verdict != nav.WrongDirection {
t.Fatalf("005: Keihin-Tohoku mot Omiya ska vara fel håll, fick %v", tr)
}
l6, err := LoadFile(filepath.Join(dir, "006-ikebukuro.json"))
if err != nil {
t.Fatal(err)
}
got := map[Tile]nav.Verdict{}
for _, tr := range l6.Trains {
got[Tile(tr.Platform[0])] = tr.Verdict
}
want := map[Tile]nav.Verdict{Platform: nav.Direct, Platform2: nav.Direct, WrongPlatform: nav.WrongDirection, Platform3: nav.TooFar}
for tl, v := range want {
if got[tl] != v {
t.Errorf("006 perrong %c: %v, vill ha %v", tl, got[tl], v)
}
}
all, err := LoadAll(dir)
if err != nil || len(all) < 7 {
t.Fatalf("LoadAll: %v (%d banor)", err, len(all))
}
}
func TestUngatedPlatforms(t *testing.T) {
// Perrong utan spärr framför -> rapporteras.
l := mustParse(t, sample)