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

@@ -80,7 +80,9 @@ func LoadAssets() (*Assets, error) {
}
a.Tiles[t] = sub(i)
}
a.Tiles[level.WrongPlatform] = a.Tiles[level.Platform] // samma utseende; tåget avgör
for _, t := range level.PlatformTiles[1:] {
a.Tiles[t] = a.Tiles[level.Platform] // samma utseende; tåget avgör
}
a.Train = sub(trainIndex)
a.Rails = sub(railsIndex)
for i := 0; i < posterCount; i++ {

View File

@@ -12,6 +12,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
@@ -45,6 +46,7 @@ type Game struct {
Catalog *progress.Catalog
Levels []*level.Level // alla banor, sorterade på id
Audio *Audio // nil = tyst
Mode nav.Mode // navigeringsläge (= State.NavMode), växlas på bankartan
savePath string // "" = spara inte
fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar)
@@ -69,6 +71,7 @@ type Options struct {
Mute bool // inget ljud
SavePath string // sparfil ("" = standardplatsen)
NoSave bool // läs sparfilen men skriv aldrig
Mode string // navigeringsläge från start: "list" eller "signs" (tomt = sparfilens)
}
// New laddar resurser, sparfil och bygger spelet.
@@ -109,6 +112,10 @@ func New(opt Options) (*Game, error) {
}
g.State = st
g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress
g.Mode = nav.ParseMode(st.NavMode)
if opt.Mode != "" {
g.Mode = nav.ParseMode(opt.Mode)
}
if !opt.NoSave {
g.savePath = path
}
@@ -233,10 +240,21 @@ func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions {
return sim.RunOptions{
Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed,
Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery,
RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, Mode: g.Mode,
}
}
// toggleMode växlar navigeringsläge och sparar.
func (g *Game) toggleMode() {
if g.Mode == nav.List {
g.Mode = nav.Signs
} else {
g.Mode = nav.List
}
g.State.NavMode = g.Mode.String()
g.Save()
}
// startLevel startar (eller återupptar) en bana och sparar seeden så att en
// laddad omgång ger samma slump.
func (g *Game) startLevel(id string) (*LevelScene, error) {

View File

@@ -126,6 +126,9 @@ func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJust
// phoneJustPressed: P = kortet i telefonen av/på (meny).
func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) }
// modeJustPressed: N växlar navigeringsläge (lista/skyltar) på bankartan.
func modeJustPressed() bool { return virtual.pressed["n"] || inpututil.IsKeyJustPressed(ebiten.KeyN) }
// mapJustPressed: M eller gamepadens vänstra mittknapp (Back/Select) = minikartan av/på.
func mapJustPressed() bool {
return virtual.pressed["m"] || inpututil.IsKeyJustPressed(ebiten.KeyM) || gamepadJustPressed(ebiten.StandardGamepadButtonCenterLeft)

View File

@@ -24,6 +24,10 @@ var minimapColors = map[level.Tile]color.RGBA{
level.Spawn: {99, 199, 77, 255},
level.Platform: {254, 174, 52, 255},
level.WrongPlatform: {254, 174, 52, 255},
level.Platform2: {254, 174, 52, 255},
level.Platform3: {254, 174, 52, 255},
level.Platform4: {254, 174, 52, 255},
level.Platform5: {254, 174, 52, 255},
level.Gate: {44, 232, 245, 255},
level.TicketMachine: {247, 118, 34, 255},
level.Sign: {90, 140, 230, 255},
@@ -111,7 +115,7 @@ func (s *LevelScene) drawMinimap(g *Game, screen *ebiten.Image, box image.Rectan
}
t := l.At(p)
c := minimapColors[t]
if (t == level.Platform || t == level.WrongPlatform) && r.TrainVisible(t) {
if t.IsPlatform() && r.TrainVisible(t) {
c = color.RGBA{255, 255, 255, 255} // tåget är inne
}
vector.DrawFilledRect(screen, float32(x0+x*sc), float32(y0+y*sc), float32(sc), float32(sc), c, false)

View File

@@ -15,6 +15,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim"
)
@@ -104,6 +105,31 @@ func (s *LevelScene) RideAtDest() bool {
return !ok || g.Index == g.Dest
}
// drawModeHint ritar navigeringslägets ruta nere till vänster när inforutan
// är tom: listläget säger vilket tåg man ska ta, skyltläget påminner om att
// man får lista ut det själv (och om bonusen).
func (s *LevelScene) drawModeHint(a *Assets, screen *ebiten.Image) {
r := s.run
txt, col := "", colGood
if r.Mode == nav.Signs {
txt, col = a.T("hud.mode_signs"), colWarn
} else if next := r.NextTrains(); len(next) > 0 {
txt = fmt.Sprintf("%s %s %s %s", a.T("hud.take"), strings.ToLower(a.T("hud.track")), next[0].Track, next[0].Line.Get("romaji"))
if len(next) > 1 {
txt += fmt.Sprintf(" (+%d)", len(next)-1)
}
}
if txt == "" {
return
}
y := ScreenH - LineH - 6
vector.DrawFilledRect(screen, 0, float32(y), float32(a.Width(txt)+8), float32(LineH+6), colPanel, false)
a.Draw(screen, txt, 4, y+3, col)
}
// LevelOver rapporterar om banan är avgjord (skriptläget: "waitover").
func (s *LevelScene) LevelOver() bool { return s.run.Outcome != sim.Ongoing }
func (s *LevelScene) followPlayer() {
x, y := s.run.Player.WorldPos(TileSize)
s.cam.Follow(x+TileSize/2, y+TileSize/2)
@@ -165,6 +191,20 @@ func (s *LevelScene) MiniPick(text string) bool {
s.run.StepMini(0, minigame.Input{Move: 1})
return false
}
case *minigame.CarPick:
if text == "calm" { // lugnaste vagnen som inte är kvinnovagn
best := -1
for i, c := range m.Cars {
if !c.WomenOnly && (best < 0 || c.Crowd < m.Cars[best].Crowd) {
best = i
}
}
if best < 0 || m.Cursor == best {
return true
}
s.run.StepMini(0, minigame.Input{MoveX: 1})
return false
}
case *minigame.TopUp:
switch m.Stage {
case minigame.TopUpPickMember:
@@ -345,6 +385,9 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) {
return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta
}
lines, accent, bar := s.infoLines(a)
if len(lines) == 0 {
s.drawModeHint(a, screen)
}
if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec {
lines = append(lines, plainLine(fmt.Sprintf("%s: %s = %s (%s)", a.T("hud.learned"), r.Learned,
entry.Translation(UILang), entry.Romaji), colKnown))
@@ -499,10 +542,18 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar
}
return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false
case level.TicketMachine:
if lvl.TicketRequired {
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false
// Terminalen: linjetavlan för stationen (nätkopplade banor), läst genom kunskapen som en skylt.
if term := r.TerminalLines(); len(term) > 0 {
lines = append(lines, plainLine(a.T("hud.terminal"), colDim))
for _, t := range term {
rd := lang.Render(a.Dict, r.Know, t, UILang)
lines = append(lines, a.wrapLine(shownLine(rd.Partial, colText, false), maxW)...)
}
}
return []line{plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)}, colGood, false
if lvl.TicketRequired {
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)), colGood, false
}
return append(lines, plainLine(fmt.Sprintf(a.T("hud.machine_topup"), r.Wallet.Card, r.Wallet.Cash), colGood)), colGood, false
}
if r.DeniedAt >= 0 && r.Time-r.DeniedAt < deniedShowSec {
key := "hud.gate_denied"

View File

@@ -101,6 +101,8 @@ func (m *MapScene) Update(g *Game) error {
case phoneJustPressed():
g.Wallet.Phone = !g.Wallet.Phone
g.Save()
case modeJustPressed():
g.toggleMode()
}
if n := len(m.mains); n > 0 {
mx, my := miniMoveXY()
@@ -209,7 +211,7 @@ func (m *MapScene) Draw(g *Game, screen *ebiten.Image) {
foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points,
a.T("hud.cash"), g.Wallet.Cash)
a.Draw(screen, foot, 8, 30, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim)
a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff)+" "+fmt.Sprintf(a.T("menu.mode"), a.T("mode."+g.Mode.String())), 8, 42, colDim)
if len(g.Profile.Debuffs) > 0 {
var parts []string
for _, d := range g.Profile.Debuffs {

View File

@@ -62,8 +62,12 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH
if run.Outcome == sim.Lost {
reason := a.T(run.LostReason)
if run.LostReason == sim.LostWrongTrain {
reason = fmt.Sprintf(a.T("result.wrong_detail"), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
if detail, ok := map[string]string{
sim.LostWrongTrain: "result.wrong_detail",
sim.LostWrongDirection: "result.wrong_direction_detail",
sim.LostTooFar: "result.too_far_detail",
}[run.LostReason]; ok {
reason += " " + fmt.Sprintf(a.T(detail), tr.Destination.Get("romaji"), run.Level.Destination.Get("romaji"))
}
for _, l := range a.wrapLine(plainLine(reason, colText), ScreenW-56) {
a.drawLines(screen, []line{l}, 28, y)
@@ -80,11 +84,26 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) {
y += LineH
if run.Outcome == sim.Won {
sc := run.Score
a.Draw(screen, fmt.Sprintf("%s %d", a.T("result.score"), sc.Total), 28, y, colText)
y += LineH
a.Draw(screen, fmt.Sprintf("%s %d %s %d %s %d %s %d %s %d %s %d", strings.ToLower(a.T("hud.time")), sc.Time, a.T("result.money"), sc.Money,
a.T("result.meters"), sc.Meters, a.T("result.map"), sc.Explore, a.T("result.car"), sc.Car, a.T("result.penalty"), sc.Penalty), 28, y, colDim)
y += LineH
// Poängen som en uträkning; radbryts till högst två rader.
parts := []string{
fmt.Sprintf("%s %d", strings.ToLower(a.T("hud.time")), sc.Time), fmt.Sprintf("%s %d", a.T("result.money"), sc.Money),
fmt.Sprintf("%s %d", a.T("result.meters"), sc.Meters), fmt.Sprintf("%s %d", a.T("result.map"), sc.Explore),
fmt.Sprintf("%s %d", a.T("result.car"), sc.Car),
}
if sc.Bonus > 0 {
parts = append(parts, fmt.Sprintf("%s %d", a.T("result.bonus"), sc.Bonus))
}
total := fmt.Sprintf("%s %d = %s", a.T("result.score"), sc.Total, strings.Join(parts, " + "))
if sc.Penalty > 0 {
total += fmt.Sprintf(" - %s %d", a.T("result.penalty"), sc.Penalty)
}
for i, l := range a.wrapLine(plainLine(total, colText), ScreenW-56) {
if i > 1 {
break
}
a.drawLines(screen, []line{l}, 28, y)
y += LineH
}
msg := fmt.Sprintf("+%d %s", run.PointsEarned, a.T("result.lang_points"))
if len(run.LearnedDest) > 0 {
msg += " " + a.T("hud.learned") + ": " + strings.Join(run.LearnedDest, " ") + " (" + a.T("result.destination") + ")"

View File

@@ -93,7 +93,7 @@ func parseScript(src string) (*script, error) {
return nil, fmt.Errorf("script: '%s TEXT'", f[0])
}
st.arg = f[1]
case "quit", "waittrain", "waitstop":
case "quit", "waittrain", "waitstop", "waitover":
default:
return nil, fmt.Errorf("script: okänt kommando %q", f[0])
}
@@ -126,6 +126,7 @@ type playerTiler interface {
type trainWaiter interface {
TrainHere() bool
RideAtDest() bool
LevelOver() bool
MiniPick(text string) bool
TrackPhaseName() string
}
@@ -195,6 +196,11 @@ func (s *script) tick(g *Game, frame *ebiten.Image) error {
if tw, ok := g.scene.(trainWaiter); !ok || tw.RideAtDest() {
s.advance()
}
case "waitover":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.LevelOver() || s.counter > 60*120 {
s.advance()
}
case "waitphase":
s.counter++
if tw, ok := g.scene.(trainWaiter); !ok || tw.TrackPhaseName() == st.arg || s.counter > 60*60 {

View File

@@ -36,7 +36,7 @@ type trackInfo struct {
func (s *LevelScene) buildTracks() {
s.tracks = nil
s.rails = map[level.Point]bool{}
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, t := range level.PlatformTiles {
for _, p := range s.run.Level.Find(t) {
tt, horizontal := s.run.Level.TrackTile(p)
s.tracks = append(s.tracks, trackInfo{tile: t, platform: p, track: tt, horizontal: horizontal})

View File

@@ -7,6 +7,7 @@ import (
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
@@ -79,3 +80,27 @@ func TestAllJapaneseInLevelsIsInDictionary(t *testing.T) {
}
}
}
// TestNetworkNamesInDictionary: alla stations- och linjenamn i linjenätet
// finns i ordboken, så att tågresans stationer och terminalen går att lära sig.
func TestNetworkNamesInDictionary(t *testing.T) {
d := loadDict(t)
path, err := res.Path("data", "network.json")
if err != nil {
t.Fatal(err)
}
n, err := nav.LoadFile(path)
if err != nil {
t.Fatal(err)
}
for _, s := range n.Stations {
if u := d.Unknown(s.Name["ja"]); len(u) > 0 {
t.Errorf("station %s (%s) saknas i ordboken: %s", s.ID, s.Name["ja"], strings.Join(u, ", "))
}
}
for _, l := range n.Lines {
if u := d.Unknown(l.Name["ja"]); len(u) > 0 {
t.Errorf("linje %s (%s) saknas i ordboken: %s", l.ID, l.Name["ja"], strings.Join(u, ", "))
}
}
}

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)

View File

@@ -34,23 +34,42 @@ func NewRide(seed rng.Seed, dest string, fillers []string, stops int, correctTra
f := append([]string(nil), fillers...)
r.shuffle(len(f), func(i, j int) { f[i], f[j] = f[j], f[i] })
stops = max(2, stops)
crowd = max(0, min(2, crowd))
sec := RideStopSec(crowd)
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: -1, Crowd: crowd}
di := -1
if correctTrain {
g.Dest = 1 + r.intn(stops-1)
di = 1 + r.intn(stops-1)
}
var names []string
for i := 0; i < stops; i++ {
switch {
case i == g.Dest:
g.Stops = append(g.Stops, dest)
case i == di:
names = append(names, dest)
case len(f) > 0:
g.Stops = append(g.Stops, f[0])
names = append(names, f[0])
f = f[1:]
default:
g.Stops = append(g.Stops, UnknownStop) // slut på namn: en namnlös station
names = append(names, UnknownStop) // slut på namn: en namnlös station
}
}
return newRide(r, names, di, crowd)
}
// NewRideStops bygger resan av givna stationer i ordning (linjenätets
// riktiga stationer); dest är resmålets index, -1 om det inte kommer.
func NewRideStops(seed rng.Seed, stops []string, dest int, crowd int) *Ride {
r := newRand(seed, "ride")
if len(stops) < 2 {
stops = append(append([]string(nil), stops...), UnknownStop, UnknownStop)[:2]
}
if dest >= len(stops) {
dest = -1
}
return newRide(r, append([]string(nil), stops...), dest, crowd)
}
func newRide(r *randSrc, stops []string, dest int, crowd int) *Ride {
crowd = max(0, min(2, crowd))
sec := RideStopSec(crowd)
g := &Ride{StopSec: sec, Timer: sec, GotOff: -1, Dest: dest, Crowd: crowd, Stops: stops}
g.WantsOut = make([]bool, len(g.Stops))
chance := [...]float64{0, 0.4, 0.7}[crowd]
for i := range g.Stops {

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)
}
}

View File

@@ -39,6 +39,7 @@ type State struct {
Profile *sim.Profile `json:"profile"`
Progress *progress.Progress `json:"progress"`
Current *RunRef `json:"current,omitempty"`
NavMode string `json:"navMode,omitempty"` // "list" (standard) eller "signs"
}
// New är ett nytt spel.

View File

@@ -84,7 +84,7 @@ func (f *Fog) Reveal(l *level.Level, from level.Point) {
// starten och spärrarna – det man orienterar sig efter.
func Landmark(l *level.Level, p level.Point) bool {
switch l.At(p) {
case level.Platform, level.WrongPlatform, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
case level.Platform, level.WrongPlatform, level.Platform2, level.Platform3, level.Platform4, level.Platform5, level.Spawn, level.Gate, level.StairsUp, level.StairsDown:
return true
}
return false

128
internal/sim/nav_test.go Normal file
View File

@@ -0,0 +1,128 @@
package sim
import (
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
// boardAt går till perrongen med tecknet tile, väntar in tåget, kliver på
// och väljer vagn; resan pågår när funktionen återvänder.
func boardAt(t *testing.T, r *Run, tile level.Tile) *level.Train {
t.Helper()
r.NPCs = nil // folket ska inte stå i vägen för testboten
r.Party.nextEventAt = 1e9
l := r.Level
path := l.Path(r.Player.Tile, l.Find(tile)[0], r.PathCanEnter)
if path == nil {
t.Fatalf("ingen väg till perrong %c", tile)
}
walk(t, r, path)
tr := waitForTrain(t, r)
r.Step(frame, None, true)
if r.MiniKind == MiniCarPick {
pickCar(t, r)
}
if r.MiniKind != MiniRide {
t.Fatalf("ingen tågresa (kind=%v)", r.MiniKind)
}
return tr
}
// TestNetworkRideUsesRealStops: på nätkopplade banor är tågresans stationer
// linjens riktiga stationer i färdriktningen, med resmålet på rätt plats.
func TestNetworkRideUsesRealStops(t *testing.T) {
l := loadLevel(t, "005-ueno")
if l.Net == nil {
t.Fatal("005 ska vara kopplad till nätet")
}
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
tr := boardAt(t, r, level.Platform)
g := r.Mini.(*minigame.Ride)
if tr.Verdict != nav.Direct || g.Dest != 1 || g.Stops[0] != "御徒町" || g.Stops[1] != "秋葉原" || len(g.Stops) != l.RideStops {
t.Fatalf("Yamanote från Ueno: dest=%d stops=%v", g.Dest, g.Stops)
}
ride(t, r)
if r.Outcome != Won {
t.Fatalf("outcome=%v (%s)", r.Outcome, r.LostReason)
}
}
// TestWrongDirectionLoses: Keihin-Tohoku mot Omiya från Ueno går åt fel håll
// – resan visar stationerna norrut, resmålet kommer aldrig och banan förloras
// med orsaken "fel håll".
func TestWrongDirectionLoses(t *testing.T) {
l := loadLevel(t, "005-ueno")
r := NewRun(l, RunOptions{Seed: rng.FromInt(5)})
tr := boardAt(t, r, level.WrongPlatform)
g := r.Mini.(*minigame.Ride)
if tr.Verdict != nav.WrongDirection || g.Dest != -1 || g.Stops[0] != "鶯谷" {
t.Fatalf("Keihin-Tohoku mot Omiya: verdict=%v dest=%d stops=%v", tr.Verdict, g.Dest, g.Stops)
}
ride(t, r)
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
}
// TestTooFarLoses: Marunouchi från Ikebukuro når Shinjuku först via city –
// för långt bort.
func TestTooFarLoses(t *testing.T) {
l := loadLevel(t, "006-ikebukuro")
r := NewRun(l, RunOptions{Seed: rng.FromInt(6)})
if n := r.NextTrains(); len(n) != 2 {
t.Fatalf("006 ska ha två rätta tåg, har %d", len(n))
}
tr := boardAt(t, r, level.Platform3)
if tr.Verdict != nav.TooFar || tr.LineID != "marunouchi" {
t.Fatalf("q-perrongen: %v %s", tr.Verdict, tr.LineID)
}
ride(t, r)
if r.Outcome != Lost || r.LostReason != LostTooFar {
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
}
}
// TestSignsModeBonus: skyltläget ger 50 % bonus på grundpoängen, listläget inte.
func TestSignsModeBonus(t *testing.T) {
l := loadLevel(t, "001-flygplatsen")
list := NewRun(l, RunOptions{Seed: rng.FromInt(1)})
signs := NewRun(l, RunOptions{Seed: rng.FromInt(1), Mode: nav.Signs})
if list.Mode != nav.List || signs.Mode != nav.Signs {
t.Fatal("läget ska följa RunOptions")
}
for _, r := range []*Run{list, signs} {
r.Time = 30
r.computeScore()
}
base := list.Score.Time + list.Score.Money + list.Score.Meters + list.Score.Explore + list.Score.Car
if list.Score.Bonus != 0 || signs.Score.Bonus != base*ScoreSignsBonusPct/100 || signs.Score.Total != list.Score.Total+signs.Score.Bonus {
t.Fatalf("list=%+v signs=%+v", list.Score, signs.Score)
}
if n := list.NextTrains(); len(n) != 1 || !n[0].Correct {
t.Fatalf("NextTrains=%v", n)
}
}
// TestTerminalLines: terminalen listar linjerna vid stationen med grannstationer.
func TestTerminalLines(t *testing.T) {
r := NewRun(loadLevel(t, "005-ueno"), RunOptions{Seed: rng.FromInt(5)})
lines := r.TerminalLines()
if len(lines) < 3 {
t.Fatalf("Ueno har flera linjer, fick %v", lines)
}
joined := strings.Join(lines, "\n")
for _, want := range []string{"山手線: ", "御徒町", "鶯谷", "← 上野 →", "京浜東北線"} {
if !strings.Contains(joined, want) {
t.Errorf("terminalen saknar %q:\n%s", want, joined)
}
}
plain := NewRun(loadLevel(t, "002b-kabukicho"), RunOptions{Seed: rng.FromInt(1)})
if plain.TerminalLines() != nil || plain.Level.Net != nil {
t.Fatal("okopplad bana har ingen terminal")
}
}

View File

@@ -41,7 +41,7 @@ const (
// npcTargets är rutor som folk vill till.
func npcTargets(l *level.Level) []level.Point {
var out []level.Point
for _, t := range []level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Platform, level.WrongPlatform, level.Spawn} {
for _, t := range append([]level.Tile{level.Kiosk, level.Toilet, level.Sign, level.TicketMachine, level.Spawn}, level.PlatformTiles...) {
out = append(out, l.Find(t)...)
}
return out

View File

@@ -7,6 +7,7 @@ import (
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/minigame"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
@@ -32,6 +33,7 @@ type Run struct {
DeniedAt float64 // när spärren senast nekade (för HUD); < 0 = aldrig
Outcome Outcome
Boarded *level.Train
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
Dict *lang.Dictionary
@@ -120,9 +122,12 @@ const (
// ScoreBreakdown är poängen för en vunnen bana.
type ScoreBreakdown struct {
Time, Money, Meters, Explore, Car, Penalty, Total int
Time, Money, Meters, Explore, Car, Bonus, Penalty, Total int
}
// ScoreSignsBonusPct är skyltlägets bonus i procent av grundpoängen.
const ScoreSignsBonusPct = 50
// Poängformelns vikter.
const (
ScoreTimeMax = 500 // par eller snabbare
@@ -163,9 +168,11 @@ const (
// Förlustorsaker (textnycklar).
const (
LostWrongTrain = "result.lost_wrong_train"
LostWrongStop = "result.lost_wrong_stop"
LostMissedStop = "result.lost_missed_stop"
LostWrongTrain = "result.lost_wrong_train"
LostWrongDirection = "result.lost_wrong_direction" // linjen går dit, men åt andra hållet
LostTooFar = "result.lost_too_far" // fel varv runt ringlinjen / lång omväg
LostWrongStop = "result.lost_wrong_stop"
LostMissedStop = "result.lost_missed_stop"
)
// Flash-nycklar.
@@ -204,6 +211,8 @@ type RunOptions struct {
FreeGates bool // spärrarna kostar inget och kräver inget
RevealAll bool // hela banan känd från start
InfiniteBattery bool // telefonen laddar inte ur
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
}
// DefaultSpeed är spelarens grundfart i rutor per sekund.
@@ -264,7 +273,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
}
r.npcRand = newNPCRand(o.Seed)
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, t := range level.PlatformTiles {
r.platformPhase[t], _ = r.PlatformPhase(t)
}
if o.Extra != (Stats{}) {
@@ -283,6 +292,7 @@ func NewRun(l *level.Level, o RunOptions) *Run {
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
r.parSec = float64(max(0, n-1))*ParSecPerTile + ParSlackSec
}
r.Mode = o.Mode
return r
}
@@ -572,9 +582,87 @@ func (r *Run) startRide() {
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
}
dest := r.Level.Destination.Get("ja")
if stops, di := r.networkStops(tr); stops != nil {
r.Mini, r.MiniKind = minigame.NewRideStops(r.miniSeed(), stops, di, r.carCrowd()), MiniRide
return
}
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct, r.carCrowd()), MiniRide
}
// networkStops ger tågresans stationer från linjenätet: de riktiga
// stationerna i tågets färdriktning och var resmålet ligger bland dem
// (-1 = det kommer inte, eller inte inom det som visas). nil utan nät.
func (r *Run) networkStops(tr *level.Train) ([]string, int) {
l := r.Level
if l.Net == nil {
return nil, -1
}
ids := l.Net.Stops(tr.LineID, l.Station, tr.Terminus, nav.MaxStops+2)
di := -1
for i, id := range ids {
if id == l.To {
di = i
break
}
}
count := l.RideStops
if di >= 0 {
count = max(count, di+2) // resmålet ska inte vara sista stationen: man ska hinna kliva av
}
count = min(count, len(ids))
if di >= count {
di = -1 // för långt bort: resmålet ligger bortom det vi visar
}
stops := make([]string, count)
for i := range stops {
stops[i] = l.Net.StationName(ids[i], "ja")
}
return stops, di
}
// NextTrains är tågen som tar en rätt – listläget visar dem i HUD:en.
func (r *Run) NextTrains() []*level.Train {
var out []*level.Train
for i := range r.Level.Trains {
if r.Level.Trains[i].Correct {
out = append(out, &r.Level.Trains[i])
}
}
return out
}
// TerminalStops är hur många stationer åt varje håll terminalen visar.
const TerminalStops = 3
// TerminalLines är linjetavlan vid automaten ("terminalen") på nätkopplade
// banor: varje linje vid stationen med de närmaste stationerna åt båda
// hållen, på japanska – att läsa genom sin kunskap som en skylt.
func (r *Run) TerminalLines() []string {
l := r.Level
if l.Net == nil {
return nil
}
var out []string
for _, ln := range l.Net.LinesAt(l.Station) {
s := ln.Name["ja"] + ": "
if back := l.Net.StopsDir(ln.ID, l.Station, -1, TerminalStops); len(back) > 0 {
for i := len(back) - 1; i >= 0; i-- {
s += l.Net.StationName(back[i], "ja") + " "
}
s += "← "
}
s += l.Net.StationName(l.Station, "ja")
if fwd := l.Net.StopsDir(ln.ID, l.Station, 1, TerminalStops); len(fwd) > 0 {
s += " →"
for _, id := range fwd {
s += " " + l.Net.StationName(id, "ja")
}
}
out = append(out, s)
}
return out
}
// StepMini för ett pågående minispel framåt; klockan och sällskapet tickar
// under tiden men spelaren står still.
func (r *Run) StepMini(dt float64, in minigame.Input) {
@@ -649,7 +737,14 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
case g.Success():
r.finish(tr, true, "")
case !tr.Correct:
r.finish(tr, false, LostWrongTrain)
reason := LostWrongTrain
switch tr.Verdict {
case nav.WrongDirection:
reason = LostWrongDirection
case nav.TooFar:
reason = LostTooFar
}
r.finish(tr, false, reason)
case g.GotOff >= 0:
r.finish(tr, false, LostWrongStop)
default:
@@ -739,7 +834,11 @@ func (r *Run) computeScore() {
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
sc.Car = ScoreCarBonus
}
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore+sc.Car-sc.Penalty)
base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
if r.Mode == nav.Signs {
sc.Bonus = base * ScoreSignsBonusPct / 100
}
sc.Total = max(0, base+sc.Bonus-sc.Penalty)
r.Score = sc
}
@@ -770,7 +869,7 @@ func (r *Run) earnedDebuffs() []Debuff {
// stepNPCs flyttar folket, låter avstigande kliva av när dörrarna öppnas och
// tar bort dem som nått utgången.
func (r *Run) stepNPCs(dt float64) {
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, t := range level.PlatformTiles {
ph, _ := r.PlatformPhase(t)
if r.platformPhase[t] != ph {
r.platformPhase[t] = ph
@@ -806,7 +905,7 @@ func (r *Run) FacilityProgress() float64 {
// till avgång respektive ankomst.
func (r *Run) PlatformHere() (tr *level.Train, atPlatform bool, seconds float64) {
t := r.Level.At(r.Player.Tile)
if t != level.Platform && t != level.WrongPlatform {
if !t.IsPlatform() {
return nil, false, 0
}
tr = r.Level.TrainAt(t)
@@ -830,7 +929,7 @@ func (r *Run) PlatformPhase(t level.Tile) (level.Phase, float64) {
// spelaren – det är där man ser och hör tåget komma.
func (r *Run) NearTrack(maxDist int) (tr *level.Train, platform level.Point, phase level.Phase, progress float64, dist int, ok bool) {
best := maxDist + 1
for _, t := range []level.Tile{level.Platform, level.WrongPlatform} {
for _, t := range level.PlatformTiles {
for _, p := range r.Level.Find(t) {
if p.Z != r.Player.Tile.Z {
continue // man ser och hör bara tågen på sin egen våning