All checks were successful
release / build-release (push) Successful in 1m36s
Ett tåg kan vara en buss (vehicle: bus): samma tidtabell, faser, bedömning i nätet och ombordstigning, men ett fordon på en väg i stället för vagnar på räls, hållplatsskylt, "1番" på tavlan, tuta vid inrullning och dörrsignal före avgång. Nätet har tre busslinjer från Narita. Nya sidobanor: 001b Flygbussterminalen (biljettbana med tre bussar) och 003b Harajuku (Yamanote åt rätt eller fel håll runt ringen). Overload-flaggan ger en rullande reklamticker och maximalt med affischer (Ueno, Tokyo). Romaji utan makroner (fonten saknar dem). Skärmdumparna som börjar på bankartan går via titelskärmen. Tester, docs och wiki uppdaterade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
980 lines
32 KiB
Go
980 lines
32 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
|
||
Vehicle string `json:"vehicle"` // "" = tåg, "bus" = buss (väg i stället för räls, hållplats i stället för perrong)
|
||
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)
|
||
Melody string `json:"melody"` // stationens avgångsmelodi: "a".."d" (tomt = vald efter id)
|
||
Overload bool `json:"overload"` // överstimulerande UI: reklamticker, blinkande reklam, extra affischer
|
||
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 l.Melody != "" && (len(l.Melody) != 1 || l.Melody[0] < 'a' || l.Melody[0] > 'd') {
|
||
return fmt.Errorf("level %s: melody måste vara a–d", 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.Vehicle != "" && tr.Vehicle != "bus" {
|
||
return fmt.Errorf("level %s: tåg %d har okänt fordon %q (tomt eller bus)", l.ID, i, tr.Vehicle)
|
||
}
|
||
if tr.Cars == 0 {
|
||
tr.Cars = DefaultCars
|
||
if tr.IsBus() {
|
||
tr.Cars = 1
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// PathOutside är Path som inte går genom betalda områden (innanför
|
||
// spärrarna) utom dem som start eller mål ligger i – så att en bot inte
|
||
// checkar in vid fel perrong på vägen (spärrar utan återvändo).
|
||
func (l *Level) PathOutside(from, to Point, canEnter func(Point) bool) []Point {
|
||
inside := l.InsideGates()
|
||
allowed := map[Point]bool{}
|
||
for _, seed := range []Point{from, to} {
|
||
if !inside[seed] || allowed[seed] {
|
||
continue
|
||
}
|
||
allowed[seed] = true
|
||
queue := []Point{seed}
|
||
for len(queue) > 0 {
|
||
p := queue[0]
|
||
queue = queue[1:]
|
||
for _, n := range l.Neighbors(p) {
|
||
if inside[n] && !allowed[n] {
|
||
allowed[n] = true
|
||
queue = append(queue, n)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return l.Path(from, to, func(p Point) bool { return canEnter(p) && (!inside[p] || allowed[p]) })
|
||
}
|
||
|
||
// 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番線 成田エクスプレス 新宿方面"
|
||
// (för bussar "1番 リムジンバス 新宿方面": hållplatsnummer i stället för spår).
|
||
func (tr *Train) BoardText() string {
|
||
if tr.IsBus() {
|
||
return tr.Track + "番 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
|
||
}
|
||
return tr.Track + "番線 " + tr.Line.Get("ja") + " " + tr.Destination.Get("ja") + "方面"
|
||
}
|
||
|
||
// IsBus rapporterar om fordonet är en buss.
|
||
func (tr *Train) IsBus() bool { return tr.Vehicle == "bus" }
|
||
|
||
// 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
|
||
}
|