M11: oändligt läge och New Game+
All checks were successful
release / build-release (push) Successful in 1m34s

internal/endless lottar mål ur en seed: en av storyns nätkopplade
stationer (aldrig samma två gånger i rad) med ett resmål som minst ett
tåg går rakt till men inte alla; banan kopieras, bedöms om mot nätet,
sällskapet växer var tredje mål och tidsgränsen krymper. O på bankartan
(eller -endless) startar en omgång; klarade mål ger poäng och XP, Enter
ger nästa mål, förlust eller Esc avslutar och omgången hamnar i
sparfilens poänglista (fem bästa). G två gånger startar New Game+: items,
banresultat, pengar och debuffar nollställs, skills, XP och japanskan
behålls, sällskapet är en person större per varv. Docs, wiki, tester och
skärmdumpar uppdaterade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 23:20:12 +02:00
parent 46bd330dfc
commit 36a45563ec
22 changed files with 556 additions and 19 deletions

115
internal/endless/endless.go Normal file
View File

@@ -0,0 +1,115 @@
// Package endless är oändligt läge: storyns stationer med slumpade resmål,
// mål efter mål tills man förlorar. Allt lottas ur en seed så att samma
// seed ger samma följd. Ren Go.
package endless
import (
"fmt"
"sort"
"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/rng"
)
// Goal är ett mål: en station (en kopia av storyns bana) med nytt resmål.
type Goal struct {
Step int // 0-baserat
Base string // ursprungliga banans id
Level *level.Level // kopian, bedömd mot nätet
}
// Svårighetsramp och standardvärden.
const (
MaxParty = 5 // sällskapet växer med ett var tredje mål, upp till så här många
PartyStepEvery = 3 // mål per extra person
DefaultTimeLimit = 420 // s, för banor utan egen tidsgräns
MinTimeLimit = 180 // s, golvet när tidsgränsen krymper
TimeLimitShrinkPct = 5 // procent kortare tidsgräns per mål
)
// Pool är stationerna som kan bli mål: alla nätkopplade banor (även
// delbanor och sidobanor), sorterade på id.
func Pool(levels []*level.Level) []*level.Level {
var out []*level.Level
for _, l := range levels {
if l.Networked() && l.Net != nil {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Destinations är stationerna som fungerar som resmål från banan: minst
// ett tåg går rakt dit, minst ett gör det inte (en fälla), och inte fler
// än nav.MaxRoutes rätta. Sorterade på id.
func Destinations(l *level.Level) []string {
var out []string
for _, s := range l.Net.Stations {
if s.ID == l.Station {
continue
}
correct := 0
for _, tr := range l.Trains {
if l.Net.Judge(tr.LineID, l.Station, tr.Terminus, s.ID) == nav.Direct {
correct++
}
}
if correct >= 1 && correct <= nav.MaxRoutes && correct < len(l.Trains) {
out = append(out, s.ID)
}
}
sort.Strings(out)
return out
}
// Plan ger målet för ett steg ur seeden. Stationen väljs så att den aldrig
// är samma som föregående stegs; resmålet lottas bland stationens möjliga.
func Plan(levels []*level.Level, seed rng.Seed, step int) (*Goal, error) {
pool := Pool(levels)
if len(pool) == 0 {
return nil, fmt.Errorf("endless: inga nätkopplade banor")
}
idx := baseIndex(len(pool), seed, step)
base := pool[idx]
dests := Destinations(base)
if len(dests) == 0 {
return nil, fmt.Errorf("endless: %s har inga möjliga resmål", base.ID)
}
r := seed.Derive(fmt.Sprintf("endless:dest:%d", step)).New()
dest := dests[r.IntN(len(dests))]
c := *base
c.Trains = append([]level.Train(nil), base.Trains...)
c.Next = ""
c.To = dest
name := base.Net.Station(dest).Name
c.Destination = level.Text{"ja": name["ja"], "romaji": nav.Name(name, "romaji"), "en": nav.Name(name, "en"), "sv": nav.Name(name, "sv")}
c.Party = min(MaxParty, max(1, base.Party)+step/PartyStepEvery)
limit := base.TimeLimitSec
if limit == 0 {
limit = DefaultTimeLimit
}
for i := 0; i < step; i++ {
limit = limit * (100 - TimeLimitShrinkPct) / 100
}
c.TimeLimitSec = max(MinTimeLimit, limit)
if err := c.Annotate(base.Net); err != nil {
return nil, err
}
return &Goal{Step: step, Base: base.ID, Level: &c}, nil
}
// baseIndex väljer station för ett steg: ett seedat hopp från föregående
// stegs station, så att två mål i rad aldrig är samma station.
func baseIndex(n int, seed rng.Seed, step int) int {
idx := seed.Derive("endless:base:0").New().IntN(n)
for s := 1; s <= step; s++ {
r := seed.Derive(fmt.Sprintf("endless:base:%d", s)).New()
if n > 1 {
idx = (idx + 1 + r.IntN(n-1)) % n
}
}
return idx
}

View File

@@ -0,0 +1,115 @@
package endless
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"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
)
func loadLevels(t *testing.T) []*level.Level {
t.Helper()
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
all, err := level.LoadAll(dir)
if err != nil {
t.Fatal(err)
}
return all
}
// TestPlanIsDeterministicAndValid: samma seed ger samma följd, andra seeds
// en annan; varje mål har rätt tåg, en fälla, nytt resmål och ingen kedja;
// två mål i rad är aldrig samma station; sällskapet växer.
func TestPlanIsDeterministicAndValid(t *testing.T) {
all := loadLevels(t)
if len(Pool(all)) < 5 {
t.Fatalf("poolen ska ha storyns stationer, har %d", len(Pool(all)))
}
seed := rng.FromInt(11)
prev := ""
var bases, dests []string
for step := 0; step < 12; step++ {
g, err := Plan(all, seed, step)
if err != nil {
t.Fatal(err)
}
again, _ := Plan(all, seed, step)
if again.Base != g.Base || again.Level.To != g.Level.To {
t.Fatalf("steg %d: inte deterministiskt", step)
}
l := g.Level
if l.Next != "" || l.To == l.Station || l.Net == nil || l.Destination.Get("ja") == "" {
t.Fatalf("steg %d: %+v", step, l)
}
correct, wrong := 0, 0
for _, tr := range l.Trains {
if tr.Verdict == nav.Unjudged {
t.Fatalf("steg %d: obedömt tåg", step)
}
if tr.Correct {
correct++
} else {
wrong++
}
}
if correct < 1 || correct > nav.MaxRoutes || wrong == 0 {
t.Fatalf("steg %d (%s → %s): rätt=%d fel=%d", step, g.Base, l.To, correct, wrong)
}
if g.Base == prev {
t.Fatalf("steg %d: samma station två gånger i rad (%s)", step, g.Base)
}
if l.TimeLimitSec < MinTimeLimit || l.Party < 1 || l.Party > MaxParty {
t.Fatalf("steg %d: tidsgräns %d sällskap %d", step, l.TimeLimitSec, l.Party)
}
prev = g.Base
bases = append(bases, g.Base)
dests = append(dests, l.To)
}
// Rampen: sällskapet är större längre fram än i början.
first, _ := Plan(all, seed, 0)
late, _ := Plan(all, seed, 9)
if late.Level.Party <= first.Level.Party && first.Level.Party < MaxParty {
t.Fatalf("sällskapet ska växa: %d → %d", first.Level.Party, late.Level.Party)
}
if late.Level.TimeLimitSec >= first.Level.TimeLimitSec && first.Level.TimeLimitSec > MinTimeLimit {
t.Fatalf("tidsgränsen ska krympa: %d → %d", first.Level.TimeLimitSec, late.Level.TimeLimitSec)
}
// Annan seed ger en annan följd.
diff := false
for step := 0; step < 12 && !diff; step++ {
g, err := Plan(all, rng.FromInt(12), step)
if err != nil {
t.Fatal(err)
}
diff = g.Base != bases[step] || g.Level.To != dests[step]
}
if !diff {
t.Fatal("två seeds gav samma följd")
}
// Originalbanorna rörs inte.
for _, l := range all {
if l.ID == bases[0] && l.To == dests[0] && l.Next == "" && l.IsLeg() == false {
// kan råka sammanfalla om lotten valde originalets resmål – kontrollera att Trains är orörda
}
}
}
func TestDestinationsNeedTrapAndRoute(t *testing.T) {
all := loadLevels(t)
for _, l := range Pool(all) {
d := Destinations(l)
if len(d) == 0 {
t.Errorf("%s: inga möjliga resmål", l.ID)
}
for _, id := range d {
if id == l.Station {
t.Errorf("%s: resmål = stationen själv", l.ID)
}
}
}
}