M9: navigeringslägen, linjenät och terminal
All checks were successful
release / build-release (push) Successful in 1m34s
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:
335
internal/nav/nav.go
Normal file
335
internal/nav/nav.go
Normal 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
171
internal/nav/nav_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user