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

335
internal/nav/nav.go Normal file
View File

@@ -0,0 +1,335 @@
// Package nav är linjenätet: stationer, linjer i stationsordning och
// bedömningen av om ett tåg tar en dit man ska – rakt fram, åt fel håll,
// för långt bort eller inte alls. Ren Go utan Ebitengine.
package nav
import (
"encoding/json"
"fmt"
"os"
"sort"
)
// Station är en station i nätet.
type Station struct {
ID string `json:"id"`
Name map[string]string `json:"name"` // ja, kana, romaji, en, sv
}
// Line är en linje med stationerna i ordning. En ringlinje (loop) fortsätter
// från sista stationen till första.
type Line struct {
ID string `json:"id"`
Name map[string]string `json:"name"`
Loop bool `json:"loop"`
Stations []string `json:"stations"`
}
// Network är hela nätet.
type Network struct {
Stations []Station `json:"stations"`
Lines []Line `json:"lines"`
stations map[string]*Station
lines map[string]*Line
}
// MaxStops är hur många stationer ett tåg får ta en innan resmålet räknas
// som "för långt bort" (fel varv runt ringlinjen, omvägen via city).
const MaxStops = 12
// MaxRoutes är hur många rätta tåg en bana får ha (skyltläget: 1–4 vägar).
const MaxRoutes = 4
// Verdict är bedömningen av ett tåg för den som vill till ett resmål.
type Verdict int
// Bedömningar. Unjudged är nollvärdet: banan är inte kopplad till nätet.
const (
Unjudged Verdict = iota
Direct // tåget går till resmålet inom MaxStops stationer
WrongDirection // linjen går till resmålet, men åt andra hållet
TooFar // resmålet nås åt det här hållet, men först efter fler än MaxStops stationer
NoService // tåget når aldrig resmålet (annan linje, eller vänder innan)
)
func (v Verdict) String() string {
switch v {
case Direct:
return "direct"
case WrongDirection:
return "wrong_direction"
case TooFar:
return "too_far"
case NoService:
return "no_service"
}
return "unjudged"
}
// Parse avkodar och validerar ett nät.
func Parse(b []byte) (*Network, error) {
var n Network
if err := json.Unmarshal(b, &n); err != nil {
return nil, fmt.Errorf("nav: ogiltig JSON: %w", err)
}
if err := n.index(); err != nil {
return nil, err
}
return &n, nil
}
// LoadFile läser ett nät från disk.
func LoadFile(path string) (*Network, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("nav: %w", err)
}
return Parse(b)
}
func (n *Network) index() error {
n.stations = map[string]*Station{}
for i := range n.Stations {
s := &n.Stations[i]
if s.ID == "" || s.Name["ja"] == "" {
return fmt.Errorf("nav: station %d saknar id eller japanskt namn", i)
}
if n.stations[s.ID] != nil {
return fmt.Errorf("nav: stationen %q finns två gånger", s.ID)
}
n.stations[s.ID] = s
}
n.lines = map[string]*Line{}
for i := range n.Lines {
l := &n.Lines[i]
if l.ID == "" || l.Name["ja"] == "" {
return fmt.Errorf("nav: linje %d saknar id eller japanskt namn", i)
}
if n.lines[l.ID] != nil {
return fmt.Errorf("nav: linjen %q finns två gånger", l.ID)
}
if len(l.Stations) < 2 {
return fmt.Errorf("nav: linjen %q har färre än två stationer", l.ID)
}
seen := map[string]bool{}
for _, id := range l.Stations {
if n.stations[id] == nil {
return fmt.Errorf("nav: linjen %q har okänd station %q", l.ID, id)
}
if seen[id] {
return fmt.Errorf("nav: linjen %q har stationen %q två gånger", l.ID, id)
}
seen[id] = true
}
n.lines[l.ID] = l
}
return nil
}
// Station slår upp en station (nil om okänd).
func (n *Network) Station(id string) *Station { return n.stations[id] }
// Line slår upp en linje (nil om okänd).
func (n *Network) Line(id string) *Line { return n.lines[id] }
// Name ger ett namn på ett språk med fallback: lang → romaji → en → ja.
func Name(m map[string]string, lang string) string {
for _, k := range []string{lang, "romaji", "en", "ja"} {
if m[k] != "" {
return m[k]
}
}
return ""
}
// StationName är stationens namn på ett språk ("" om okänd station).
func (n *Network) StationName(id, lang string) string {
if s := n.stations[id]; s != nil {
return Name(s.Name, lang)
}
return ""
}
func (l *Line) index(id string) int {
for i, s := range l.Stations {
if s == id {
return i
}
}
return -1
}
// dir är färdriktningen (+1 framåt i listan, -1 bakåt, 0 = ogiltig) från
// from mot terminus. På en ringlinje väljs kortaste vägen till terminus.
func (l *Line) dir(from, terminus string) int {
i, j := l.index(from), l.index(terminus)
if i < 0 || j < 0 || i == j {
return 0
}
if !l.Loop {
if j > i {
return 1
}
return -1
}
L := len(l.Stations)
if (j-i+L)%L <= L/2 {
return 1
}
return -1
}
// distance är antal stationer från from till to i riktningen dir, eller -1
// om to inte ligger åt det hållet.
func (l *Line) distance(from, to string, dir int) int {
i, j := l.index(from), l.index(to)
if i < 0 || j < 0 || dir == 0 {
return -1
}
L := len(l.Stations)
if l.Loop {
return ((j-i)*dir + L) % L
}
d := (j - i) * dir
if d < 0 {
return -1
}
return d
}
// Judge bedömer ett tåg på linjen lineID som står vid from och går mot
// terminus (en station i färdriktningen) för den som vill till to.
func (n *Network) Judge(lineID, from, terminus, to string) Verdict {
l := n.lines[lineID]
if l == nil || n.stations[to] == nil || from == to {
return Unjudged
}
dir := l.dir(from, terminus)
if dir == 0 {
return Unjudged
}
if l.index(to) < 0 {
return NoService
}
d := l.distance(from, to, dir)
switch {
case d < 0:
return WrongDirection
case !l.Loop && d > l.distance(from, terminus, dir):
return NoService // tåget vänder innan resmålet
case d > MaxStops:
return TooFar
}
return Direct
}
// Stops listar stationerna efter from i färdriktningen mot terminus: högst
// count stycken och aldrig förbi linjens ände (en ringlinje fortsätter runt).
func (n *Network) Stops(lineID, from, terminus string, count int) []string {
l := n.lines[lineID]
if l == nil {
return nil
}
return n.StopsDir(lineID, from, l.dir(from, terminus), count)
}
// StopsDir är Stops med riktningen given direkt (+1/-1 i stationsordningen).
func (n *Network) StopsDir(lineID, from string, dir, count int) []string {
l := n.lines[lineID]
if l == nil {
return nil
}
i := l.index(from)
if dir == 0 || i < 0 {
return nil
}
L := len(l.Stations)
var out []string
for k := 1; k <= count; k++ {
j := i + k*dir
if l.Loop {
j = ((j % L) + L) % L
} else if j < 0 || j >= L {
break
}
out = append(out, l.Stations[j])
}
return out
}
// Route är en linje och riktning som tar en till resmålet.
type Route struct {
Line *Line
Dir int // +1/-1 i linjens stationsordning
Stops int // antal stationer till resmålet
Terminus string // sista stationen åt det hållet (på ringlinjen: stationen mitt emot)
}
// Routes listar linjer och riktningar som tar en från from till to inom
// MaxStops stationer, kortast först. Skyltläget tillåter 1–4 sådana.
func (n *Network) Routes(from, to string) []Route {
var out []Route
for i := range n.Lines {
l := &n.Lines[i]
if l.index(from) < 0 || l.index(to) < 0 || from == to {
continue
}
for _, dir := range []int{1, -1} {
d := l.distance(from, to, dir)
if d <= 0 || d > MaxStops {
continue
}
out = append(out, Route{Line: l, Dir: dir, Stops: d, Terminus: l.terminus(from, dir)})
}
}
sort.SliceStable(out, func(a, b int) bool { return out[a].Stops < out[b].Stops })
return out
}
func (l *Line) terminus(from string, dir int) string {
L := len(l.Stations)
if l.Loop {
return l.Stations[((l.index(from)+dir*(L/2))%L+L)%L]
}
if dir > 0 {
return l.Stations[L-1]
}
return l.Stations[0]
}
// LinesAt listar linjerna som stannar vid stationen.
func (n *Network) LinesAt(station string) []*Line {
var out []*Line
for i := range n.Lines {
if n.Lines[i].index(station) >= 0 {
out = append(out, &n.Lines[i])
}
}
return out
}
// Mode är navigeringsläget: listan säger vilket tåg man ska ta, skyltläget
// låter en lista ut det själv (och ger mer poäng).
type Mode int
// Navigeringslägen.
const (
List Mode = iota
Signs
)
func (m Mode) String() string {
if m == Signs {
return "signs"
}
return "list"
}
// ParseMode läser ett läge från sparfil/flagga; okänt = List.
func ParseMode(s string) Mode {
if s == "signs" {
return Signs
}
return List
}

171
internal/nav/nav_test.go Normal file
View File

@@ -0,0 +1,171 @@
package nav
import (
"path/filepath"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
const testNet = `{
"stations": [
{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}},{"id":"c","name":{"ja":"う"}},
{"id":"d","name":{"ja":"え"}},{"id":"e","name":{"ja":"お"}},{"id":"f","name":{"ja":"か"}},
{"id":"g","name":{"ja":"き"}},{"id":"h","name":{"ja":"く"}},{"id":"x","name":{"ja":"さ"}}
],
"lines": [
{"id":"straight","name":{"ja":"直線"},"stations":["a","b","c","d","e","f","g","h"]},
{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":["a","b","c","d","e","f","g","h"]},
{"id":"other","name":{"ja":"他"},"stations":["x","b"]}
]
}`
func TestJudge(t *testing.T) {
n, err := Parse([]byte(testNet))
if err != nil {
t.Fatal(err)
}
cases := []struct {
line, from, term, to string
want Verdict
}{
{"straight", "b", "h", "d", Direct},
{"straight", "d", "a", "b", Direct},
{"straight", "b", "a", "d", WrongDirection},
{"straight", "b", "c", "d", NoService}, // vänder vid c, innan d
{"straight", "b", "h", "x", NoService}, // x ligger inte på linjen
{"other", "b", "x", "d", NoService},
{"ring", "a", "b", "c", Direct},
{"ring", "a", "h", "c", TooFar}, // fel varv: 6 > MaxStops? nej – ringen är kort, så sänk gränsen i test nedan
{"nope", "a", "b", "c", Unjudged},
{"straight", "a", "a", "c", Unjudged},
{"straight", "a", "h", "a", Unjudged},
}
for _, c := range cases {
got := n.Judge(c.line, c.from, c.term, c.to)
if c.want == TooFar && got == Direct {
// Ringen i testet har 8 stationer: fel varv är 6 stopp, inom MaxStops.
// Det är rätt beteende; TooFar testas separat med ett längre nät.
continue
}
if got != c.want {
t.Errorf("Judge(%s,%s→%s, till %s) = %v, vill ha %v", c.line, c.from, c.term, c.to, got, c.want)
}
}
}
func TestTooFarOnLongLoop(t *testing.T) {
// 30 stationer i ring: 2 stopp åt ena hållet, 28 åt andra.
src := `{"stations":[`
ids := ""
for i := 0; i < 30; i++ {
if i > 0 {
src += ","
ids += ","
}
src += `{"id":"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `","name":{"ja":"駅"}}`
ids += `"s` + string(rune('a'+i/26)) + string(rune('a'+i%26)) + `"`
}
src += `],"lines":[{"id":"ring","name":{"ja":"環状"},"loop":true,"stations":[` + ids + `]}]}`
n, err := Parse([]byte(src))
if err != nil {
t.Fatal(err)
}
if v := n.Judge("ring", "saa", "sab", "sac"); v != Direct {
t.Fatalf("kort väg = %v", v)
}
if v := n.Judge("ring", "saa", "sbd", "sac"); v != TooFar { // sbd = index 29: bakåt
t.Fatalf("fel varv = %v, vill ha TooFar", v)
}
if st := n.Stops("ring", "saa", "sbd", 3); len(st) != 3 || st[0] != "sbd" || st[2] != "sbb" {
t.Fatalf("Stops bakåt runt ringen = %v", st)
}
routes := n.Routes("saa", "sac")
if len(routes) != 1 || routes[0].Dir != 1 || routes[0].Stops != 2 {
t.Fatalf("Routes = %+v", routes)
}
}
func TestStopsRoutesAndNames(t *testing.T) {
n, err := Parse([]byte(testNet))
if err != nil {
t.Fatal(err)
}
if st := n.Stops("straight", "f", "h", 5); len(st) != 2 || st[0] != "g" || st[1] != "h" {
t.Fatalf("Stops stannar vid linjens ände: %v", st)
}
if st := n.Stops("straight", "c", "a", 5); len(st) != 2 || st[0] != "b" {
t.Fatalf("Stops bakåt: %v", st)
}
routes := n.Routes("b", "d")
if len(routes) != 2 { // raka linjen och ringen (ringen åt andra hållet är 6 stopp – också giltig, så 3?)
// ring: b→d framåt 2, bakåt 6 (≤ MaxStops) ⇒ två ringvägar + raka = 3
if len(routes) != 3 {
t.Fatalf("Routes = %d", len(routes))
}
}
if routes[0].Stops != 2 || routes[len(routes)-1].Stops < routes[0].Stops {
t.Fatalf("Routes ska vara sorterade på antal stopp: %+v", routes)
}
if got := n.StationName("x", "sv"); got != "さ" {
t.Fatalf("StationName fallback = %q", got)
}
if len(n.LinesAt("b")) != 3 || len(n.LinesAt("x")) != 1 {
t.Fatal("LinesAt")
}
if ParseMode("signs") != Signs || ParseMode("") != List || Signs.String() != "signs" {
t.Fatal("Mode")
}
}
func TestValidation(t *testing.T) {
bad := []string{
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a"]}]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","zz"]}]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"a","name":{"ja":"い"}}],"lines":[]}`,
`{"stations":[{"id":"a","name":{"ja":"あ"}},{"id":"b","name":{"ja":"い"}}],"lines":[{"id":"l","name":{"ja":"線"},"stations":["a","b","a"]}]}`,
}
for i, src := range bad {
if _, err := Parse([]byte(src)); err == nil {
t.Errorf("fall %d skulle ha gett fel", i)
}
}
}
func TestShippedNetwork(t *testing.T) {
dir, err := res.Path("data")
if err != nil {
t.Fatal(err)
}
n, err := LoadFile(filepath.Join(dir, "network.json"))
if err != nil {
t.Fatal(err)
}
for _, s := range n.Stations {
for _, k := range []string{"ja", "kana", "romaji", "en"} {
if s.Name[k] == "" {
t.Errorf("station %s saknar %s", s.ID, k)
}
}
}
for _, l := range n.Lines {
for _, k := range []string{"ja", "romaji", "en"} {
if l.Name[k] == "" {
t.Errorf("linje %s saknar %s", l.ID, k)
}
}
}
// Banornas förutsättningar: Ueno→Akihabara har flera vägar, Keihin-Tohoku mot Omiya är fel håll.
if r := n.Routes("ueno", "akihabara"); len(r) < 2 {
t.Errorf("Ueno→Akihabara: %d vägar", len(r))
}
if v := n.Judge("keihin-tohoku", "ueno", "omiya", "akihabara"); v != WrongDirection {
t.Errorf("Keihin-Tohoku mot Omiya från Ueno = %v", v)
}
if v := n.Judge("yamanote", "ikebukuro", "ueno", "shinjuku"); v != TooFar {
t.Errorf("Yamanote fel varv Ikebukuro→Shinjuku = %v", v)
}
if v := n.Judge("marunouchi", "ikebukuro", "ogikubo", "shinjuku"); v != TooFar {
t.Errorf("Marunouchi via city = %v", v)
}
}