All checks were successful
release / build-release (push) Successful in 2m7s
Björns tillägg 2026-10-24. Med egna kort och fler i sällskapet frågar kiosken vem som betalar (minigame.Payer: kontant, spelarens kort eller en följares kort; den som inte har råd nekas, Esc avbryter). Avgiften dras nu när man passerar spärren in i det betalda området (level.InsideGates), inte på spärrrutan. Banflaggan oneWayGates: incheckad släpps inte ut igen förrän man åkt; Enter vid spärren kallar på personal (10 s) som låser upp korten; ny inpassering kostar igen. På i 005, 007/007b och 008/008b. Skriptkommandon urge och push. Docs, wiki och skärmdumpar uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
935 lines
31 KiB
Go
935 lines
31 KiB
Go
// Package level definierar banformatet och ren spellogik kring det
|
||
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
|
||
// kan därför testas utan fönster.
|
||
//
|
||
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
|
||
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
|
||
package level
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
|
||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/nav"
|
||
)
|
||
|
||
// Tile är ett tecken i rutnätet.
|
||
type Tile byte
|
||
|
||
// Tecknen som får förekomma i ett rutnät.
|
||
const (
|
||
Wall Tile = '#' // vägg, ej passerbar
|
||
Floor Tile = '.' // golv
|
||
Spawn Tile = 'S' // startpunkt (exakt en)
|
||
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
|
||
Toilet Tile = 'W' // toalett (sällskapets toamätare)
|
||
Kiosk Tile = 'K' // kiosk/automat för mat och dryck
|
||
StairsUp Tile = 'U' // trappa upp: leder till 'D' på samma x,y en våning upp
|
||
StairsDown Tile = 'D' // trappa ner: leder till 'U' på samma x,y en våning ner
|
||
LaneLeft Tile = '<' // fil för gång uppåt/åt vänster (bredare gångar: håll till vänster)
|
||
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 }
|
||
|
||
// IsStairs rapporterar om rutan är en trappa.
|
||
func (t Tile) IsStairs() bool { return t == StairsUp || t == StairsDown }
|
||
|
||
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
|
||
func (t Tile) Passable() bool { return t != Wall }
|
||
|
||
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
|
||
type Text map[string]string
|
||
|
||
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
|
||
func (t Text) Get(lang string) string {
|
||
if s, ok := t[lang]; ok {
|
||
return s
|
||
}
|
||
if s, ok := t["en"]; ok {
|
||
return s
|
||
}
|
||
for _, s := range t {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Schedule är ett tågs tidtabell i sekunder från banstart: första ankomst
|
||
// vid OffsetSec, därefter var IntervalSec; tåget står DwellSec vid perrongen.
|
||
type Schedule struct {
|
||
OffsetSec float64 `json:"offsetSec"`
|
||
IntervalSec float64 `json:"intervalSec"`
|
||
DwellSec float64 `json:"dwellSec"`
|
||
}
|
||
|
||
// Standardvärden som fylls i av Validate när fältet är 0.
|
||
const (
|
||
DefaultIntervalSec = 30.0
|
||
DefaultDwellSec = 10.0
|
||
DefaultFareYen = 200
|
||
MaxParty = 5
|
||
DefaultRideStops = 3
|
||
)
|
||
|
||
// Status returnerar om tåget står vid perrongen vid tiden t, och hur många
|
||
// sekunder det är kvar – till avgång om det står där, annars till ankomst.
|
||
func (s Schedule) Status(t float64) (atPlatform bool, seconds float64) {
|
||
if t < s.OffsetSec {
|
||
return false, s.OffsetSec - t
|
||
}
|
||
phase := math.Mod(t-s.OffsetSec, s.IntervalSec)
|
||
if phase < s.DwellSec {
|
||
return true, s.DwellSec - phase
|
||
}
|
||
return false, s.IntervalSec - phase
|
||
}
|
||
|
||
// Phase är var i cykeln ett tåg befinner sig.
|
||
type Phase int
|
||
|
||
// Tågfaser i ordning över en cykel.
|
||
const (
|
||
Away Phase = iota // borta
|
||
Approaching // på väg in: melodi, lampor, vind (ApproachSec före ankomst)
|
||
Arriving // rullar in och bromsar (ArriveSec), dörrarna stängda
|
||
Dwelling // står inne med öppna dörrar
|
||
Closing // strax avgång: dörrsignal (sista WarnSec av uppehållet)
|
||
Leaving // rullar ut (LeaveSec efter avgång)
|
||
)
|
||
|
||
func (p Phase) String() string {
|
||
return [...]string{"away", "approaching", "arriving", "dwelling", "closing", "leaving"}[p]
|
||
}
|
||
|
||
// Fasernas längder i sekunder.
|
||
const (
|
||
ApproachSec = 5.0
|
||
ArriveSec = 1.5
|
||
WarnSec = 3.0
|
||
LeaveSec = 1.5
|
||
MinDwellSec = ArriveSec + WarnSec + 1 // minst en sekund öppna dörrar utan signal
|
||
)
|
||
|
||
// Phase returnerar fasen vid tiden t och hur långt in i fasen man är (0..1).
|
||
func (s Schedule) Phase(t float64) (Phase, float64) {
|
||
var phi float64 // tid sedan senaste ankomst, kan vara negativ före första
|
||
if t < s.OffsetSec {
|
||
phi = t - s.OffsetSec
|
||
if -phi <= ApproachSec {
|
||
return Approaching, 1 + phi/ApproachSec
|
||
}
|
||
return Away, 0
|
||
} else {
|
||
phi = math.Mod(t-s.OffsetSec, s.IntervalSec)
|
||
}
|
||
switch {
|
||
case phi < ArriveSec:
|
||
return Arriving, phi / ArriveSec
|
||
case phi < s.DwellSec-WarnSec:
|
||
return Dwelling, (phi - ArriveSec) / max(0.001, s.DwellSec-WarnSec-ArriveSec)
|
||
case phi < s.DwellSec:
|
||
return Closing, (phi - (s.DwellSec - WarnSec)) / WarnSec
|
||
case phi < s.DwellSec+LeaveSec:
|
||
return Leaving, (phi - s.DwellSec) / LeaveSec
|
||
case phi >= s.IntervalSec-ApproachSec:
|
||
return Approaching, (phi - (s.IntervalSec - ApproachSec)) / ApproachSec
|
||
default:
|
||
return Away, (phi - s.DwellSec - LeaveSec) / max(0.001, s.IntervalSec-ApproachSec-s.DwellSec-LeaveSec)
|
||
}
|
||
}
|
||
|
||
// Boardable rapporterar om dörrarna är öppna så att man kan kliva på.
|
||
func (s Schedule) Boardable(t float64) bool {
|
||
p, _ := s.Phase(t)
|
||
return p == Dwelling || p == Closing
|
||
}
|
||
|
||
// Present rapporterar om tåget syns vid perrongen (rullar in, står, rullar ut).
|
||
func (s Schedule) Present(t float64) bool {
|
||
p, _ := s.Phase(t)
|
||
return p == Arriving || p == Dwelling || p == Closing || p == Leaving
|
||
}
|
||
|
||
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
|
||
type Train struct {
|
||
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
|
||
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 (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.
|
||
const (
|
||
DefaultCars = 3
|
||
MaxCars = 8
|
||
)
|
||
|
||
// WomenOnlyActive rapporterar om kvinnovagnen gäller vid bantiden t.
|
||
func (tr *Train) WomenOnlyActive(t float64) bool {
|
||
if tr.WomenOnlyCar == 0 {
|
||
return false
|
||
}
|
||
if tr.WomenOnlyFrom == 0 && tr.WomenOnlyTo == 0 {
|
||
return true
|
||
}
|
||
return t >= float64(tr.WomenOnlyFrom) && t < float64(tr.WomenOnlyTo)
|
||
}
|
||
|
||
// SignText är texten på en skyltruta ('i').
|
||
type SignText struct {
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
Z int `json:"z"` // våning (0 = första i floors)
|
||
Text Text `json:"text"`
|
||
}
|
||
|
||
// Storey är en våning: ett rutnät med namn ("B1", "1F", "2F").
|
||
type Storey struct {
|
||
Name string `json:"name"`
|
||
Grid []string `json:"grid"`
|
||
Floors []Storey `json:"floors,omitempty"` // flervåningsbana; alla våningar lika stora
|
||
}
|
||
|
||
// Rewards är vad en klarad bana ger utöver poäng.
|
||
type Rewards struct {
|
||
Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången
|
||
SkillPoints int `json:"skillPoints"` // extra skillpoäng första gången
|
||
}
|
||
|
||
// Level är en bana.
|
||
type Level struct {
|
||
ID string `json:"id"`
|
||
Kind string `json:"kind"` // "main" (standard) eller "side"
|
||
Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns
|
||
Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad
|
||
Next string `json:"next"` // nästa delbana (kind "leg") i en resa med byten: när man klivit av här fortsätter banan där
|
||
Rewards Rewards `json:"rewards"`
|
||
Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval)
|
||
Crowd int `json:"crowd"` // folk på stationen 0–3 (NPC:er)
|
||
Clutter int `json:"clutter"` // visuell röra 0–3 (affischer, lockskyltar)
|
||
Noise int `json:"noise"` // ljudröra 0–3 (bakgrundsprat)
|
||
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)
|
||
TicketRequired bool `json:"ticketRequired"` // spärren kräver pappersbiljett (köps i automaten) i stället för kort
|
||
RideStops int `json:"rideStops"` // antal stationer på tågresan (0 = DefaultRideStops)
|
||
MaxMissedTrains int `json:"maxMissedTrains"` // >0: fler missade tåg än så förlorar banan
|
||
IndividualCards bool `json:"individualCards"` // alla i sällskapet har egna kort (och biljetter) som spärren kräver
|
||
SeatSelection bool `json:"seatSelection"` // biljettköpet fortsätter med sittplatsval efter sällskapets önskemål
|
||
OneWayGates bool `json:"oneWayGates"` // spärren släpper inte ut en igen förrän man åkt; personal vid spärren låser upp (tar tid)
|
||
FareYen int `json:"fareYen"` // vad spärren drar från kortet (0 = standard)
|
||
Grid []string `json:"grid,omitempty"` // envåningsbana (kompatibilitet) – blir Floors[0]
|
||
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.
|
||
type Point struct{ X, Y, Z int }
|
||
|
||
// Up/Down ger rutan rakt ovanför/under (via trappa).
|
||
func (p Point) Up() Point { return Point{X: p.X, Y: p.Y, Z: p.Z + 1} }
|
||
func (p Point) Down() Point { return Point{X: p.X, Y: p.Y, Z: p.Z - 1} }
|
||
|
||
// dirs är de fyra grannriktningarna på samma våning.
|
||
var dirs = [...]Point{{X: 1}, {X: -1}, {Y: 1}, {Y: -1}}
|
||
|
||
// Parse avkodar och validerar en bana.
|
||
func Parse(data []byte) (*Level, error) {
|
||
var l Level
|
||
if err := json.Unmarshal(data, &l); err != nil {
|
||
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
|
||
}
|
||
if err := l.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &l, nil
|
||
}
|
||
|
||
// Width och Height i rutor (alla våningar är lika stora).
|
||
func (l *Level) Width() int {
|
||
if len(l.Floors) == 0 || len(l.Floors[0].Grid) == 0 {
|
||
return 0
|
||
}
|
||
return len(l.Floors[0].Grid[0])
|
||
}
|
||
func (l *Level) Height() int {
|
||
if len(l.Floors) == 0 {
|
||
return 0
|
||
}
|
||
return len(l.Floors[0].Grid)
|
||
}
|
||
|
||
// NumFloors är antalet våningar.
|
||
func (l *Level) NumFloors() int { return len(l.Floors) }
|
||
|
||
// FloorName är våningens namn ("1F" om det saknas).
|
||
func (l *Level) FloorName(z int) string {
|
||
if z < 0 || z >= len(l.Floors) || l.Floors[z].Name == "" {
|
||
return "1F"
|
||
}
|
||
return l.Floors[z].Name
|
||
}
|
||
|
||
// At returnerar rutan på p; utanför kartan (eller våningarna) räknas som vägg.
|
||
func (l *Level) At(p Point) Tile { return l.FloorAt(p.Z, p.X, p.Y) }
|
||
|
||
// FloorAt returnerar rutan (x, y) på våning z; utanför kartan räknas som vägg.
|
||
func (l *Level) FloorAt(z, x, y int) Tile {
|
||
if z < 0 || z >= len(l.Floors) {
|
||
return Wall
|
||
}
|
||
g := l.Floors[z].Grid
|
||
if y < 0 || y >= len(g) || x < 0 || x >= len(g[y]) {
|
||
return Wall
|
||
}
|
||
return Tile(g[y][x])
|
||
}
|
||
|
||
// Find returnerar alla rutor av en viss typ på alla våningar.
|
||
func (l *Level) Find(t Tile) []Point {
|
||
var pts []Point
|
||
for z, f := range l.Floors {
|
||
for y, row := range f.Grid {
|
||
for x := 0; x < len(row); x++ {
|
||
if Tile(row[x]) == t {
|
||
pts = append(pts, Point{X: x, Y: y, Z: z})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// Neighbors är rutorna man kan gå till från p: de fyra grannarna på samma
|
||
// våning samt trappans andra ände. Passerbarhet avgörs av anroparen.
|
||
func (l *Level) Neighbors(p Point) []Point {
|
||
out := make([]Point, 0, 5)
|
||
for _, d := range dirs {
|
||
out = append(out, Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z})
|
||
}
|
||
switch l.At(p) {
|
||
case StairsUp:
|
||
out = append(out, p.Up())
|
||
case StairsDown:
|
||
out = append(out, p.Down())
|
||
}
|
||
return out
|
||
}
|
||
|
||
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
|
||
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
|
||
|
||
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
|
||
// exakt en start, minst en perrong, och att varje tåg står vid ett
|
||
// perrong-tecken som finns i rutnätet.
|
||
func (l *Level) Validate() error {
|
||
if l.ID == "" {
|
||
return fmt.Errorf("level: saknar id")
|
||
}
|
||
switch l.Kind {
|
||
case "":
|
||
l.Kind = "main"
|
||
case "main", "side", "leg":
|
||
default:
|
||
return fmt.Errorf("level %s: kind måste vara main, side eller leg", l.ID)
|
||
}
|
||
if l.Kind == "side" && l.Parent == "" {
|
||
return fmt.Errorf("level %s: sidobana utan parent", l.ID)
|
||
}
|
||
if l.Kind == "main" && l.Parent != "" {
|
||
return fmt.Errorf("level %s: huvudbana med parent", l.ID)
|
||
}
|
||
if l.Order < 0 {
|
||
return fmt.Errorf("level %s: negativ order", l.ID)
|
||
}
|
||
if len(l.Floors) == 0 {
|
||
if len(l.Grid) == 0 {
|
||
return fmt.Errorf("level %s: tomt rutnät", l.ID)
|
||
}
|
||
l.Floors = []Storey{{Name: "1F", Grid: l.Grid}}
|
||
} else if len(l.Grid) > 0 {
|
||
return fmt.Errorf("level %s: ange antingen grid eller floors, inte båda", l.ID)
|
||
}
|
||
l.Grid = l.Floors[0].Grid // kompatibilitet: Grid = första våningen
|
||
if len(l.Floors[0].Grid) == 0 {
|
||
return fmt.Errorf("level %s: tom första våning", l.ID)
|
||
}
|
||
w, h := len(l.Floors[0].Grid[0]), len(l.Floors[0].Grid)
|
||
spawns, platforms := 0, 0
|
||
for z, f := range l.Floors {
|
||
if len(f.Grid) != h {
|
||
return fmt.Errorf("level %s: våning %d har %d rader, förväntade %d", l.ID, z, len(f.Grid), h)
|
||
}
|
||
for y, row := range f.Grid {
|
||
if len(row) != w {
|
||
return fmt.Errorf("level %s: våning %d rad %d har längd %d, förväntade %d", l.ID, z, y, len(row), w)
|
||
}
|
||
for x := 0; x < len(row); x++ {
|
||
t := Tile(row[x])
|
||
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 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)
|
||
}
|
||
case StairsDown:
|
||
if l.FloorAt(z-1, x, y) != StairsUp {
|
||
return fmt.Errorf("level %s: trappa ner på (%d,%d,%d) saknar trappa upp under", l.ID, x, y, z)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if spawns != 1 {
|
||
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/Q/q/R/r)", l.ID)
|
||
}
|
||
if len(l.Trains) == 0 {
|
||
return fmt.Errorf("level %s: saknar tåg", l.ID)
|
||
}
|
||
correct := 0
|
||
for i := range l.Trains {
|
||
tr := &l.Trains[i]
|
||
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 {
|
||
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
|
||
}
|
||
if tr.Correct {
|
||
correct++
|
||
}
|
||
if tr.Cars == 0 {
|
||
tr.Cars = DefaultCars
|
||
}
|
||
if tr.Cars < 1 || tr.Cars > MaxCars {
|
||
return fmt.Errorf("level %s: tåg %d har %d vagnar (1–%d)", l.ID, i, tr.Cars, MaxCars)
|
||
}
|
||
if tr.WomenOnlyCar < 0 || tr.WomenOnlyCar > tr.Cars || tr.WomenOnlyFrom < 0 || tr.WomenOnlyTo < tr.WomenOnlyFrom {
|
||
return fmt.Errorf("level %s: tåg %d har ogiltig kvinnovagn (%d av %d, %d–%d s)", l.ID, i, tr.WomenOnlyCar, tr.Cars, tr.WomenOnlyFrom, tr.WomenOnlyTo)
|
||
}
|
||
sc := &tr.Schedule
|
||
if sc.IntervalSec == 0 {
|
||
sc.IntervalSec = DefaultIntervalSec
|
||
}
|
||
if sc.DwellSec == 0 {
|
||
sc.DwellSec = DefaultDwellSec
|
||
}
|
||
if sc.OffsetSec < 0 || sc.IntervalSec <= 0 || sc.DwellSec <= 0 || sc.DwellSec >= sc.IntervalSec {
|
||
return fmt.Errorf("level %s: tåg %d har orimlig tidtabell %+v (kräver offset ≥ 0, 0 < dwell < interval)", l.ID, i, *sc)
|
||
}
|
||
if sc.DwellSec < MinDwellSec || sc.IntervalSec-sc.DwellSec < LeaveSec+ApproachSec+1 {
|
||
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 && !l.Networked() {
|
||
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
|
||
}
|
||
if l.FareYen == 0 {
|
||
l.FareYen = DefaultFareYen
|
||
}
|
||
if l.FareYen < 0 || l.ParSec < 0 || l.Party < 0 {
|
||
return fmt.Errorf("level %s: negativ avgift/partid/sällskap", l.ID)
|
||
}
|
||
if l.Party == 0 {
|
||
l.Party = 1
|
||
}
|
||
if l.RideStops == 0 {
|
||
l.RideStops = DefaultRideStops
|
||
}
|
||
if l.RideStops < 2 || l.RideStops > 8 {
|
||
return fmt.Errorf("level %s: rideStops måste vara 2–8", l.ID)
|
||
}
|
||
if l.TicketRequired && len(l.Find(TicketMachine)) == 0 {
|
||
return fmt.Errorf("level %s: biljettkrav utan biljettautomat (T)", l.ID)
|
||
}
|
||
if l.SeatSelection && !l.TicketRequired {
|
||
return fmt.Errorf("level %s: sittplatsval kräver biljettbana", l.ID)
|
||
}
|
||
if l.MaxMissedTrains < 0 || l.TimeLimitSec < 0 {
|
||
return fmt.Errorf("level %s: negativ tidsgräns/maxantal missade tåg", l.ID)
|
||
}
|
||
for name, v := range map[string]int{"crowd": l.Crowd, "clutter": l.Clutter, "noise": l.Noise} {
|
||
if v < 0 || v > 3 {
|
||
return fmt.Errorf("level %s: %s måste vara 0–3", l.ID, name)
|
||
}
|
||
}
|
||
if l.Party > MaxParty {
|
||
return fmt.Errorf("level %s: sällskapet får vara högst %d", l.ID, MaxParty)
|
||
}
|
||
// Skyltar: varje post måste stå på en 'i'-ruta och varje 'i'-ruta ha text.
|
||
seen := map[Point]bool{}
|
||
for i, sg := range l.Signs {
|
||
p := Point{X: sg.X, Y: sg.Y, Z: sg.Z}
|
||
if l.At(p) != Sign {
|
||
return fmt.Errorf("level %s: skylt %d på (%d,%d,%d) står inte på en skyltruta", l.ID, i, p.X, p.Y, p.Z)
|
||
}
|
||
if seen[p] {
|
||
return fmt.Errorf("level %s: två skyltar på (%d,%d)", l.ID, p.X, p.Y)
|
||
}
|
||
if len(sg.Text) == 0 {
|
||
return fmt.Errorf("level %s: skylt på (%d,%d) saknar text", l.ID, p.X, p.Y)
|
||
}
|
||
seen[p] = true
|
||
}
|
||
for _, p := range l.Find(Sign) {
|
||
if !seen[p] {
|
||
return fmt.Errorf("level %s: skyltrutan (%d,%d,%d) saknar text i \"signs\"", l.ID, p.X, p.Y, p.Z)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SignAt returnerar skylten på en ruta, eller nil.
|
||
func (l *Level) SignAt(p Point) *SignText {
|
||
for i := range l.Signs {
|
||
if l.Signs[i].X == p.X && l.Signs[i].Y == p.Y && l.Signs[i].Z == p.Z {
|
||
return &l.Signs[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Path returnerar kortaste vägen från from till to (båda inkluderade) via
|
||
// rutor som canEnter godkänner, eller nil om ingen väg finns.
|
||
func (l *Level) Path(from, to Point, canEnter func(Point) bool) []Point {
|
||
prev := map[Point]Point{from: from}
|
||
queue := []Point{from}
|
||
for len(queue) > 0 {
|
||
p := queue[0]
|
||
queue = queue[1:]
|
||
if p == to {
|
||
var path []Point
|
||
for ; p != from; p = prev[p] {
|
||
path = append(path, p)
|
||
}
|
||
path = append(path, from)
|
||
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
||
path[i], path[j] = path[j], path[i]
|
||
}
|
||
return path
|
||
}
|
||
for _, n := range l.Neighbors(p) {
|
||
if _, ok := prev[n]; ok || !canEnter(n) {
|
||
continue
|
||
}
|
||
prev[n] = p
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Reachable returnerar alla rutor som nås från start via passerbara rutor
|
||
// (4-riktningars BFS). Spärrar räknas som passerbara.
|
||
func (l *Level) Reachable() map[Point]bool {
|
||
start := l.SpawnPoint()
|
||
seen := map[Point]bool{start: true}
|
||
queue := []Point{start}
|
||
for len(queue) > 0 {
|
||
p := queue[0]
|
||
queue = queue[1:]
|
||
for _, n := range l.Neighbors(p) {
|
||
if seen[n] || !l.At(n).Passable() {
|
||
continue
|
||
}
|
||
seen[n] = true
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
return seen
|
||
}
|
||
|
||
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
|
||
func (l *Level) Solvable() bool {
|
||
reach := l.Reachable()
|
||
for _, tr := range l.Trains {
|
||
if !tr.Correct {
|
||
continue
|
||
}
|
||
for _, p := range l.Find(Tile(tr.Platform[0])) {
|
||
if reach[p] {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// InsideGates är rutorna innanför spärrarna: allt som nås från perrongerna
|
||
// utan att passera en spärr. Används av spärrar utan återvändo.
|
||
func (l *Level) InsideGates() map[Point]bool {
|
||
seen := map[Point]bool{}
|
||
var queue []Point
|
||
for _, t := range PlatformTiles {
|
||
for _, p := range l.Find(t) {
|
||
seen[p] = true
|
||
queue = append(queue, p)
|
||
}
|
||
}
|
||
for len(queue) > 0 {
|
||
p := queue[0]
|
||
queue = queue[1:]
|
||
for _, n := range l.Neighbors(p) {
|
||
t := l.At(n)
|
||
if seen[n] || !t.Passable() || t == Gate {
|
||
continue
|
||
}
|
||
seen[n] = true
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
return seen
|
||
}
|
||
|
||
// UngatedPlatforms listar perronger som går att nå från start utan att
|
||
// passera någon spärr. Speldesignen kräver att alla perronger ligger
|
||
// innanför spärrar, så listan ska vara tom för skeppade banor.
|
||
func (l *Level) UngatedPlatforms() []Point {
|
||
start := l.SpawnPoint()
|
||
seen := map[Point]bool{start: true}
|
||
queue := []Point{start}
|
||
for len(queue) > 0 {
|
||
p := queue[0]
|
||
queue = queue[1:]
|
||
for _, n := range l.Neighbors(p) {
|
||
t := l.At(n)
|
||
if seen[n] || !t.Passable() || t == Gate {
|
||
continue
|
||
}
|
||
seen[n] = true
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
var out []Point
|
||
for _, t := range []Tile{Platform, WrongPlatform} {
|
||
for _, p := range l.Find(t) {
|
||
if seen[p] {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
|
||
// inte går att nå – praktiskt som varning när man bygger banor.
|
||
func (l *Level) Unreachable() []Point {
|
||
reach := l.Reachable()
|
||
var out []Point
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// BoardText är avgångstavlans text på japanska, t.ex. "2番線 成田エクスプレス 新宿方面".
|
||
func (tr *Train) BoardText() string {
|
||
return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
|
||
}
|
||
|
||
// TrackTile är rutan "bortom perrongkanten" där rälsen går: första väggruta
|
||
// nedanför, till höger, till vänster eller ovanför perrongen. horizontal
|
||
// anger om rälsen (och tågets rörelse) går i sidled.
|
||
func (l *Level) TrackTile(p Point) (tile Point, horizontal bool) {
|
||
for _, d := range [...]Point{{X: 0, Y: 1}, {X: 1, Y: 0}, {X: -1, Y: 0}, {X: 0, Y: -1}} {
|
||
n := Point{X: p.X + d.X, Y: p.Y + d.Y, Z: p.Z}
|
||
if l.At(n) == Wall {
|
||
return n, d.Y != 0
|
||
}
|
||
}
|
||
return p, true
|
||
}
|
||
|
||
// CorrectPlatform returnerar en perrongruta med rätt tåg (valideringen
|
||
// garanterar att det finns minst ett rätt tåg vid en befintlig perrong).
|
||
func (l *Level) CorrectPlatform() Point {
|
||
for _, tr := range l.Trains {
|
||
if tr.Correct {
|
||
if pts := l.Find(Tile(tr.Platform[0])); len(pts) > 0 {
|
||
return pts[0]
|
||
}
|
||
}
|
||
}
|
||
return l.SpawnPoint()
|
||
}
|
||
|
||
// CanEnter rapporterar om rutan går att gå in på (för sim.Mover).
|
||
func (l *Level) CanEnter(p Point) bool { return l.At(p).Passable() }
|
||
|
||
// TrainAt returnerar tåget vid en perrongruta, eller nil.
|
||
func (l *Level) TrainAt(t Tile) *Train {
|
||
for i := range l.Trains {
|
||
if Tile(l.Trains[i].Platform[0]) == t {
|
||
return &l.Trains[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
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" }
|
||
|
||
// IsLeg rapporterar om banan är en delbana i en resa med byten (nås bara
|
||
// via en annan banas next, visas inte på bankartan).
|
||
func (l *Level) IsLeg() bool { return l.Kind == "leg" }
|
||
|
||
// ChainOf ger resan som börjar med banan id: banan själv följd av dess
|
||
// delbanor i ordning. nil om id är okänt.
|
||
func ChainOf(all []*Level, id string) []*Level {
|
||
byID := map[string]*Level{}
|
||
for _, l := range all {
|
||
byID[l.ID] = l
|
||
}
|
||
var out []*Level
|
||
for l := byID[id]; l != nil && len(out) <= len(all); l = byID[l.Next] {
|
||
out = append(out, l)
|
||
if l.Next == "" {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// FinalOf är resans sista delbana (banan själv utan byten) – dess
|
||
// destination är resans resmål.
|
||
func FinalOf(all []*Level, l *Level) *Level {
|
||
if c := ChainOf(all, l.ID); len(c) > 0 {
|
||
return c[len(c)-1]
|
||
}
|
||
return l
|
||
}
|
||
|
||
// ValidateChains kontrollerar resorna med byten: next finns och är en
|
||
// delbana, varje delbana används exakt en gång, inga cykler, och på
|
||
// nätkopplade banor börjar nästa delbana där den förra slutade.
|
||
func ValidateChains(all []*Level) error {
|
||
byID := map[string]*Level{}
|
||
for _, l := range all {
|
||
byID[l.ID] = l
|
||
}
|
||
refs := map[string]int{}
|
||
for _, l := range all {
|
||
if l.Next == "" {
|
||
continue
|
||
}
|
||
n := byID[l.Next]
|
||
if n == nil {
|
||
return fmt.Errorf("level %s: next %q finns inte", l.ID, l.Next)
|
||
}
|
||
if !n.IsLeg() {
|
||
return fmt.Errorf("level %s: next %q måste ha kind leg", l.ID, l.Next)
|
||
}
|
||
refs[n.ID]++
|
||
if l.Networked() && n.Networked() && n.Station != l.To {
|
||
return fmt.Errorf("level %s: bytet hänger inte ihop – slutar i %q men %s börjar i %q", l.ID, l.To, n.ID, n.Station)
|
||
}
|
||
}
|
||
for _, l := range all {
|
||
if l.IsLeg() && refs[l.ID] != 1 {
|
||
return fmt.Errorf("level %s: delbanan refereras av %d banor, ska vara exakt 1", l.ID, refs[l.ID])
|
||
}
|
||
}
|
||
for _, l := range all {
|
||
if l.IsLeg() {
|
||
continue
|
||
}
|
||
seen := map[string]bool{}
|
||
for c := l; c != nil && c.Next != ""; c = byID[c.Next] {
|
||
if seen[c.ID] {
|
||
return fmt.Errorf("level %s: resan går i cirkel", l.ID)
|
||
}
|
||
seen[c.ID] = true
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 {
|
||
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)
|
||
}
|
||
if err := ValidateChains(out); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade.
|
||
func List(dir string) ([]string, error) {
|
||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ids := make([]string, 0, len(files))
|
||
for _, f := range files {
|
||
ids = append(ids, strings.TrimSuffix(filepath.Base(f), ".json"))
|
||
}
|
||
sort.Strings(ids)
|
||
return ids, nil
|
||
}
|