M10: byten, hemresan och levelcheck
All checks were successful
release / build-release (push) Successful in 1m37s

Resor med byten: en bana kan ha next som pekar på en delbana (kind leg,
syns inte på bankartan). När man klivit av rätt tåg fortsätter samma
omgång på nästa station med klocka, plånbok, sällskap och räknare;
par-tiden adderas, dimma och folk är nya, en Byte!-banner visas och
HUD:en visar resmålet via bytesstationen. Tidsgräns och missade tåg
gäller hela resan. level.ValidateChains kräver sammanhängande kedjor.
Nya resor: 007 Akihabara -> Ueno -> Asakusa och finalen 008 Tokyo (två
våningar) -> Nippori -> Narita flygplats. Slutscen när sista huvudbanan
klaras första gången: planet lyfter, resan summeras, eftertexter;
Progress.StoryCleared sparas; flaggan -ending. tools/levelcheck ritar
banorna och rapporterar problem. HUD-nivån kortas när mätarna tar plats;
poängraden radbryts. Skärmdumpar och docs uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:37:48 +02:00
parent f762f9554e
commit 4ae6a1d24c
36 changed files with 1681 additions and 74 deletions

View File

@@ -248,6 +248,7 @@ type Level struct {
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)
@@ -381,9 +382,9 @@ func (l *Level) Validate() error {
switch l.Kind {
case "":
l.Kind = "main"
case "main", "side":
case "main", "side", "leg":
default:
return fmt.Errorf("level %s: kind måste vara main eller side", l.ID)
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)
@@ -782,6 +783,81 @@ func (l *Level) Annotate(net *nav.Network) error {
// 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) {
@@ -810,6 +886,9 @@ func LoadAll(dir string) ([]*Level, error) {
}
out = append(out, l)
}
if err := ValidateChains(out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -665,3 +665,70 @@ func TestCarsWomenOnlyAndCrowdFields(t *testing.T) {
t.Fatal("filer")
}
}
// TestStoryChains: resorna med byten hänger ihop, storyn börjar och slutar på
// flygplatsen, och felaktiga kedjor ger fel.
func TestStoryChains(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
all, err := LoadAll(dir) // kör ValidateChains
if err != nil {
t.Fatal(err)
}
var first, last *Level
legs, chains := 0, 0
for _, l := range all {
switch {
case l.IsLeg():
legs++
if l.Order != 0 {
t.Errorf("%s: delbanor har ingen order", l.ID)
}
case !l.IsSide():
if first == nil || l.Order < first.Order {
first = l
}
if last == nil || l.Order > last.Order {
last = l
}
}
if l.Next != "" {
chains++
c := ChainOf(all, l.ID)
if len(c) < 2 || c[0] != l || FinalOf(all, l) != c[len(c)-1] {
t.Errorf("%s: ChainOf/FinalOf = %v", l.ID, c)
}
}
}
if legs == 0 || chains == 0 {
t.Fatal("storyn ska ha resor med byten")
}
if first == nil || first.Station != "narita-airport" {
t.Errorf("storyn ska börja på flygplatsen, första banan är %v", first)
}
if last == nil || FinalOf(all, last).To != "narita-airport" {
t.Errorf("storyn ska sluta på flygplatsen, sista banan slutar i %q", FinalOf(all, last).To)
}
mk := func(id, kind, next, station, to string) *Level {
return &Level{ID: id, Kind: kind, Next: next, Station: station, To: to}
}
bad := map[string][]*Level{
"saknad next": {mk("a", "main", "zzz", "", "")},
"next inte leg": {mk("a", "main", "b", "", ""), mk("b", "main", "", "", "")},
"oanvänd delbana": {mk("a", "main", "", "", ""), mk("b", "leg", "", "", "")},
"delbana två gånger": {mk("a", "main", "b", "", ""), mk("c", "main", "b", "", ""), mk("b", "leg", "", "", "")},
"cirkel": {mk("a", "main", "b", "", ""), mk("b", "leg", "c", "", ""), mk("c", "leg", "b", "", "")},
"byte hänger inte": {mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "z", "w")},
}
for name, lv := range bad {
if err := ValidateChains(lv); err == nil {
t.Errorf("%s: skulle ha gett fel", name)
}
}
if err := ValidateChains([]*Level{mk("a", "main", "b", "x", "y"), mk("b", "leg", "", "y", "w")}); err != nil {
t.Errorf("giltig kedja: %v", err)
}
}