M10: byten, hemresan och levelcheck
All checks were successful
release / build-release (push) Successful in 1m37s
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:
101
internal/sim/legs_test.go
Normal file
101
internal/sim/legs_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package sim
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
// loadChain laddar alla banor och ger resan som börjar med id.
|
||||
func loadChain(t *testing.T, id string) []*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)
|
||||
}
|
||||
c := level.ChainOf(all, id)
|
||||
if len(c) < 2 {
|
||||
t.Fatalf("%s är ingen resa med byten: %v", id, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestJourneyWithTransfer: bana 007 fortsätter på Ueno efter första tåget –
|
||||
// klockan, plånboken och sällskapet följer med, par-tiden adderas, och
|
||||
// resan vinns först när andra tåget nått Asakusa.
|
||||
func TestJourneyWithTransfer(t *testing.T) {
|
||||
c := loadChain(t, "007-akihabara")
|
||||
head, leg := c[0], c[1]
|
||||
r := NewRun(head, RunOptions{Seed: rng.FromInt(7), Chain: c[1:]})
|
||||
if r.Legs() != 2 || r.LegNo() != 1 || r.IsLastLeg() || r.FinalDestination().Get("ja") != "浅草" || r.TimeLimit() != head.TimeLimitSec {
|
||||
t.Fatalf("resans start: legs=%d no=%d final=%s limit=%d", r.Legs(), r.LegNo(), r.FinalDestination().Get("ja"), r.TimeLimit())
|
||||
}
|
||||
par1 := r.ParSec()
|
||||
for _, m := range r.Party.Members {
|
||||
m.Card = 100000 // egna kort med gott om saldo: testet handlar om bytet
|
||||
}
|
||||
r.Wallet.Card = 100000
|
||||
tr := boardAt(t, r, level.Platform)
|
||||
ride(t, r)
|
||||
if r.Outcome != Ongoing || r.Level != leg || r.LegNo() != 2 || !r.IsLastLeg() {
|
||||
t.Fatalf("efter första tåget: outcome=%v level=%s legNo=%d", r.Outcome, r.Level.ID, r.LegNo())
|
||||
}
|
||||
if r.TransferAt < 0 || r.FlashKey != FlashTransfer || r.Player.Tile != leg.SpawnPoint() || len(r.Rides) != 1 || r.Rides[0] != tr {
|
||||
t.Fatalf("bytet: transferAt=%.1f flash=%q tile=%v rides=%d", r.TransferAt, r.FlashKey, r.Player.Tile, len(r.Rides))
|
||||
}
|
||||
if r.Time < 10 || r.ParSec() <= par1 || r.Fog.Explored(leg) >= 0.5 {
|
||||
t.Fatalf("klockan ska fortsätta (%.1f), par adderas (%.0f > %.0f) och dimman är ny (%.2f)", r.Time, r.ParSec(), par1, r.Fog.Explored(leg))
|
||||
}
|
||||
if from, to := r.Party.FollowerTiles(1); from != leg.SpawnPoint() || to != leg.SpawnPoint() {
|
||||
t.Fatalf("sällskapet ska stå på nya starten: %v %v", from, to)
|
||||
}
|
||||
boardAt(t, r, level.Platform)
|
||||
ride(t, r)
|
||||
if r.Outcome != Won || len(r.Rides) != 2 || r.Score.Total <= 0 {
|
||||
t.Fatalf("resan ska vinnas efter andra tåget: outcome=%v (%s) rides=%d", r.Outcome, r.LostReason, len(r.Rides))
|
||||
}
|
||||
if r.Score.Explore <= 0 || r.Score.Explore > ScoreExploreMax {
|
||||
t.Fatalf("utforskat är snittet över delbanorna: %d", r.Score.Explore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJourneyWrongTrainOnSecondLeg: fel tåg på bytesstationen förlorar resan.
|
||||
func TestJourneyWrongTrainOnSecondLeg(t *testing.T) {
|
||||
c := loadChain(t, "007-akihabara")
|
||||
r := NewRun(c[0], RunOptions{Seed: rng.FromInt(8), Chain: c[1:]})
|
||||
for _, m := range r.Party.Members {
|
||||
m.Card = 100000
|
||||
}
|
||||
r.Wallet.Card = 100000
|
||||
boardAt(t, r, level.Platform2) // Yamanote mot Ueno – också rätt
|
||||
ride(t, r)
|
||||
if r.Outcome != Ongoing || r.LegNo() != 2 {
|
||||
t.Fatalf("första tåget: %v", r.Outcome)
|
||||
}
|
||||
boardAt(t, r, level.WrongPlatform) // Ginza mot Shibuya: fel håll
|
||||
ride(t, r)
|
||||
if r.Outcome != Lost || r.LostReason != LostWrongDirection {
|
||||
t.Fatalf("outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainLevelsLoad: alla delbanor går att läsa en och en också.
|
||||
func TestChainLevelsLoad(t *testing.T) {
|
||||
dir, err := res.Path("data", "levels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"007b-ueno", "008b-nippori"} {
|
||||
l, err := level.LoadFile(filepath.Join(dir, id+".json"))
|
||||
if err != nil || !l.IsLeg() {
|
||||
t.Fatalf("%s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,9 @@ func NewParty(n int, start level.Point, seed rng.Seed) *Party {
|
||||
return p
|
||||
}
|
||||
|
||||
// Reset ställer hela sällskapet på en ny ruta (byte till nästa delbana).
|
||||
func (p *Party) Reset(at level.Point) { p.trail = []level.Point{at} }
|
||||
|
||||
// Size är antalet i sällskapet inkl. spelaren.
|
||||
func (p *Party) Size() int { return len(p.Members) }
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ type Run struct {
|
||||
Boarded *level.Train
|
||||
Mode nav.Mode // navigeringsläge: listan visar nästa tåg, skyltläget ger bonus
|
||||
|
||||
// Resor med byten: Level är aktuell delbana; tiden, plånboken och
|
||||
// sällskapet följer med mellan delbanorna.
|
||||
LegIndex int // 0 = första delbanan
|
||||
TransferAt float64 // när senaste bytet skedde (< 0 = inget)
|
||||
Rides []*level.Train // tågen man åkt med hittills
|
||||
calmCars int // resor i en lugn vagn (bonus)
|
||||
crowdedRide bool // någon resa i en full vagn (svettig-risk)
|
||||
exploreSum float64 // utforskad andel i avklarade delbanor
|
||||
legsDone int
|
||||
timeLimit int // resans tidsgräns (första delbanans), 0 = ingen
|
||||
maxMissed int // resans gräns för missade tåg (första delbanans)
|
||||
|
||||
// Språk: ordboken, spelarens kunskap (delas mellan banor) och hjälpen.
|
||||
Dict *lang.Dictionary
|
||||
Know *lang.Knowledge
|
||||
@@ -116,6 +128,7 @@ const (
|
||||
// Fler flash-nycklar.
|
||||
const (
|
||||
FlashWomenOnly = "hud.women_only"
|
||||
FlashTransfer = "hud.transfer_done"
|
||||
FlashEarly = "hud.early_board"
|
||||
FlashBump = "hud.bump"
|
||||
)
|
||||
@@ -213,6 +226,8 @@ type RunOptions struct {
|
||||
InfiniteBattery bool // telefonen laddar inte ur
|
||||
|
||||
Mode nav.Mode // navigeringsläge (nollvärdet = listläge)
|
||||
|
||||
Chain []*level.Level // resans fortsättning: delbanorna efter den första (byten), i ordning
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -261,7 +276,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
Visits: map[level.Point]int{},
|
||||
lastTile: l.SpawnPoint(),
|
||||
ProgressedAt: -1,
|
||||
parSec: float64(l.ParSec),
|
||||
parSec: parFor(l),
|
||||
timeLimit: l.TimeLimitSec,
|
||||
maxMissed: l.MaxMissedTrains,
|
||||
TransferAt: -1,
|
||||
Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")),
|
||||
baseSpeed: o.Speed,
|
||||
NoticeAt: -1,
|
||||
@@ -288,14 +306,91 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
r.Fog.RevealAll(l)
|
||||
}
|
||||
r.Fog.Reveal(l, l.SpawnPoint())
|
||||
if r.parSec <= 0 {
|
||||
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
|
||||
}
|
||||
|
||||
// parFor är "bra tid" för en delbana: parSec i bandatan, annars kortaste
|
||||
// vägen i rutor × ParSecPerTile + ParSlackSec.
|
||||
func parFor(l *level.Level) float64 {
|
||||
if l.ParSec > 0 {
|
||||
return float64(l.ParSec)
|
||||
}
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
return float64(max(0, n-1))*ParSecPerTile + ParSlackSec
|
||||
}
|
||||
|
||||
// Legs är antalet delbanor i resan, LegNo den aktuella (1-baserad).
|
||||
func (r *Run) Legs() int { return 1 + len(r.opts.Chain) }
|
||||
func (r *Run) LegNo() int { return r.LegIndex + 1 }
|
||||
|
||||
// IsLastLeg rapporterar om det inte blir fler byten.
|
||||
func (r *Run) IsLastLeg() bool { return r.LegIndex >= len(r.opts.Chain) }
|
||||
|
||||
// FinalDestination är resans slutmål (sista delbanans destination).
|
||||
func (r *Run) FinalDestination() level.Text {
|
||||
if n := len(r.opts.Chain); n > 0 {
|
||||
return r.opts.Chain[n-1].Destination
|
||||
}
|
||||
return r.Level.Destination
|
||||
}
|
||||
|
||||
// TimeLimit och MaxMissed är resans gränser (0 = ingen).
|
||||
func (r *Run) TimeLimit() int { return r.timeLimit }
|
||||
func (r *Run) MaxMissed() int { return r.maxMissed }
|
||||
|
||||
// nextLeg är nästa delbana, eller nil på den sista.
|
||||
func (r *Run) nextLeg() *level.Level {
|
||||
if r.IsLastLeg() {
|
||||
return nil
|
||||
}
|
||||
return r.opts.Chain[r.LegIndex]
|
||||
}
|
||||
|
||||
// startLeg byter till nästa delbana: ny station, spelaren och sällskapet på
|
||||
// dess start, ny dimma och nytt folk – men klockan, plånboken, mätarna och
|
||||
// räknarna fortsätter. Par-tiden adderas.
|
||||
func (r *Run) startLeg(l *level.Level) {
|
||||
r.exploreSum += r.Fog.Explored(r.Level)
|
||||
r.legsDone++
|
||||
r.LegIndex++
|
||||
r.Level = l
|
||||
spawn := l.SpawnPoint()
|
||||
r.Player = NewMover(spawn, r.baseSpeed)
|
||||
r.Party.Reset(spawn)
|
||||
r.Fog = NewFog(l, FogRadius+r.opts.FogRadius)
|
||||
r.Fog.ExtraLandmarks = r.opts.Landmarks
|
||||
if r.opts.RevealAll {
|
||||
r.Fog.RevealAll(l)
|
||||
}
|
||||
r.Fog.Reveal(l, spawn)
|
||||
r.NPCs = spawnNPCs(l, l.Crowd*NPCPerCrowd, r.npcRand)
|
||||
r.platformPhase = map[level.Tile]level.Phase{}
|
||||
for _, t := range level.PlatformTiles {
|
||||
r.platformPhase[t], _ = r.PlatformPhase(t)
|
||||
}
|
||||
r.Revealed = map[level.Point]bool{}
|
||||
r.Visits = map[level.Point]int{}
|
||||
r.lastTile = spawn
|
||||
r.Mini, r.MiniKind, r.PendingTrain, r.CarChosen = nil, MiniNone, nil, nil
|
||||
r.Help, r.Facility = HelpState{}, FacilityState{}
|
||||
r.DeniedAt, r.BumpedAt, r.WrongLane = -1, -1, false
|
||||
r.parSec += parFor(l)
|
||||
r.TransferAt = r.Time
|
||||
r.flash(FlashTransfer)
|
||||
}
|
||||
|
||||
// rideDone bokför en avslutad tågresa (vagnbonus, svettig-risk).
|
||||
func (r *Run) rideDone(tr *level.Train) {
|
||||
r.Rides = append(r.Rides, tr)
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||||
r.calmCars++
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 2 {
|
||||
r.crowdedRide = true
|
||||
}
|
||||
}
|
||||
|
||||
// ParSec är tiden för tre stjärnor.
|
||||
func (r *Run) ParSec() float64 { return r.parSec }
|
||||
|
||||
@@ -735,6 +830,11 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
r.Blocked += g.Blocked
|
||||
switch {
|
||||
case g.Success():
|
||||
if next := r.nextLeg(); next != nil {
|
||||
r.rideDone(tr)
|
||||
r.startLeg(next) // bytet: resan fortsätter på nästa station
|
||||
return
|
||||
}
|
||||
r.finish(tr, true, "")
|
||||
case !tr.Correct:
|
||||
reason := LostWrongTrain
|
||||
@@ -801,22 +901,22 @@ func (r *Run) checkLimits() {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
|
||||
if r.timeLimit > 0 && r.Time > float64(r.timeLimit) {
|
||||
r.Mini, r.MiniKind, r.PendingTrain = nil, MiniNone, nil
|
||||
r.finish(nil, false, LostTimeUp)
|
||||
return
|
||||
}
|
||||
if r.Level.MaxMissedTrains > 0 && r.MissedTrains > r.Level.MaxMissedTrains {
|
||||
if r.maxMissed > 0 && r.MissedTrains > r.maxMissed {
|
||||
r.finish(nil, false, LostMissedTrains)
|
||||
}
|
||||
}
|
||||
|
||||
// TimeLeft är sekunder kvar av tidsgränsen (0 utan gräns).
|
||||
func (r *Run) TimeLeft() float64 {
|
||||
if r.Level.TimeLimitSec == 0 {
|
||||
if r.timeLimit == 0 {
|
||||
return 0
|
||||
}
|
||||
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
|
||||
return max(0, float64(r.timeLimit)-r.Time)
|
||||
}
|
||||
|
||||
// computeScore fyller i poängen för en vunnen bana.
|
||||
@@ -825,15 +925,13 @@ func (r *Run) computeScore() {
|
||||
sc.Time = int(ScoreTimeMax * min(1, r.parSec/max(r.Time, 0.001)))
|
||||
sc.Money = max(0, ScoreMoneyMax-(r.Wallet.Spent-r.spentStart)/10)
|
||||
sc.Meters = int(ScoreMetersMax * r.Party.AvgMeters())
|
||||
sc.Explore = int(ScoreExploreMax * r.Fog.Explored(r.Level))
|
||||
sc.Explore = int(ScoreExploreMax * (r.exploreSum + r.Fog.Explored(r.Level)) / float64(r.legsDone+1))
|
||||
sc.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy +
|
||||
PenaltyEarlyBoard*r.EarlyBoard + PenaltyBlocked*r.Blocked
|
||||
if r.WomenOnlyViolation {
|
||||
sc.Penalty += PenaltyWomenOnly
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 0 {
|
||||
sc.Car = ScoreCarBonus
|
||||
}
|
||||
sc.Car = ScoreCarBonus * r.calmCars
|
||||
base := sc.Time + sc.Money + sc.Meters + sc.Explore + sc.Car
|
||||
if r.Mode == nav.Signs {
|
||||
sc.Bonus = base * ScoreSignsBonusPct / 100
|
||||
@@ -860,7 +958,7 @@ func (r *Run) earnedDebuffs() []Debuff {
|
||||
if r.poisoned {
|
||||
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
|
||||
}
|
||||
if r.CarChosen != nil && r.CarChosen.Crowd == 2 && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
|
||||
if r.crowdedRide && r.Seed.Derive("sweaty").New().Float64() < SweatyChance/r.Stats[StatLuck] {
|
||||
out = append(out, Debuff{Sweaty, 1})
|
||||
}
|
||||
return out
|
||||
@@ -968,6 +1066,7 @@ func (r *Run) SignHere() *level.SignText {
|
||||
// tillräckligt bra resultat lär man sig resmålets ord.
|
||||
func (r *Run) finish(tr *level.Train, won bool, reason string) {
|
||||
r.Boarded = tr
|
||||
r.rideDone(tr)
|
||||
r.NewDebuffs = r.earnedDebuffs()
|
||||
if !won {
|
||||
r.Outcome = Lost
|
||||
|
||||
Reference in New Issue
Block a user