M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
All checks were successful
release / build-release (push) Successful in 1m37s
All checks were successful
release / build-release (push) Successful in 1m37s
- sim/stats.go: Stats (fart, törst, fokus, toalett, tur), debuffar som multiplikatorer, Profile som följer med mellan banor och räknar ned - sim.Run: tidsgräns och maxantal missade tåg förlorar banan; poängformel (tid mot par, pengar, sällskapets mätare, utforskat, avdrag); debuffar tjänas av många steg, lång tid, tom matmätare och seedad otur vid kiosken; stats påverkar fart, mätartakter, läsning och hjälptid - egna kort (individualCards): Member.Card, spärren kräver allas saldo och drar från alla, automaten frågar vems kort, biljetter köps åt alla - sittplatsval (seatSelection): Ticket får ett sittplatssteg med seedade önskemål (fönster/gång/sida/bredvid); missnöjda ger mätar- och poängavdrag - level: maxMissedTrains, individualCards, seatSelection; bana 003 Shibuya (svårighet 3, fyra i sällskapet) och 002 med tidsgräns och sittplatser; ordboken utökad; generatorn är spec-driven - game: tid kvar/missade i HUD, debuffar i meny/HUD/resultat, poäng och förlustorsaker på resultatet, vyer för personval och vagn, skript-pick, flaggan -debuffs; HUD och resultat komprimerade för fyra i sällskapet - plan (Björn 2026-10-24): flera våningar med trappor som M9a, egna kort och sittplatsval som svårighetsinställningar i M7 – i spelbeskrivning, roadmap, testplan - docs/README/wiki med sittplatsbilden Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -48,8 +48,16 @@ type Member struct {
|
||||
Meters Meters
|
||||
Urge Urge
|
||||
UrgeAt float64 // när behovet uppstod
|
||||
Card int // eget tågkort (yen) när banan kräver egna kort; spelaren använder plånboken
|
||||
}
|
||||
|
||||
// MemberCardYen är vad följarnas egna kort har från start (lite – de får
|
||||
// fyllas på).
|
||||
const MemberCardYen = 500
|
||||
|
||||
// Rates skalar hur fort mätarna sjunker (debuffar/stats).
|
||||
type Rates struct{ Thirst, Toilet float64 }
|
||||
|
||||
// PartyNames är namnen i den ordning de följer med.
|
||||
var PartyNames = []string{"Du", "Anna", "Ville", "Sara", "Omar"}
|
||||
|
||||
@@ -72,6 +80,7 @@ const (
|
||||
// Party är sällskapet – lämmeltåget – med mätare, behov och följarnas spår.
|
||||
type Party struct {
|
||||
Members []*Member
|
||||
Rates Rates
|
||||
trail []level.Point // ledarens rutor, senaste sist
|
||||
rand *rand.Rand
|
||||
nextEventAt float64
|
||||
@@ -83,9 +92,9 @@ type Party struct {
|
||||
// sällskapet kommer från seed.
|
||||
func NewParty(n int, start level.Point, seed rng.Seed) *Party {
|
||||
n = max(1, min(n, len(PartyNames)))
|
||||
p := &Party{rand: seed.New()}
|
||||
p := &Party{rand: seed.New(), Rates: Rates{1, 1}}
|
||||
for i := 0; i < n; i++ {
|
||||
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}})
|
||||
p.Members = append(p.Members, &Member{Name: PartyNames[i], Meters: Meters{100, 100, 100}, Card: MemberCardYen})
|
||||
}
|
||||
p.trail = []level.Point{start}
|
||||
p.nextEventAt = p.interval() * (0.5 + p.rand.Float64())
|
||||
@@ -106,8 +115,8 @@ func (p *Party) interval() float64 { return BaseEventSec / float64(p.Size()) }
|
||||
func (p *Party) Advance(dt, t float64) {
|
||||
for _, m := range p.Members {
|
||||
m.Meters.Food = max(0, m.Meters.Food-FoodPerSec*dt)
|
||||
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*dt)
|
||||
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*dt)
|
||||
m.Meters.Drink = max(0, m.Meters.Drink-DrinkPerSec*p.Rates.Thirst*dt)
|
||||
m.Meters.Toilet = max(0, m.Meters.Toilet-ToiletPerSec*p.Rates.Toilet*dt)
|
||||
if m.Urge == Distracted && t-m.UrgeAt >= DistractionSec {
|
||||
m.Urge = NoUrge
|
||||
}
|
||||
@@ -185,6 +194,24 @@ func (p *Party) SpeedFactor() float64 {
|
||||
return f
|
||||
}
|
||||
|
||||
// Names är medlemmarnas namn i ordning.
|
||||
func (p *Party) Names() []string {
|
||||
out := make([]string, len(p.Members))
|
||||
for i, m := range p.Members {
|
||||
out[i] = m.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AvgMeters är medelvärdet av allas lägsta mätare, 0..1 (poängen).
|
||||
func (p *Party) AvgMeters() float64 {
|
||||
sum := 0.0
|
||||
for _, m := range p.Members {
|
||||
sum += m.Meters.Min()
|
||||
}
|
||||
return sum / float64(len(p.Members)) / 100
|
||||
}
|
||||
|
||||
// NeedsToilet är de som har nytta av en toalett just nu.
|
||||
func (p *Party) NeedsToilet() []*Member {
|
||||
var out []*Member
|
||||
|
||||
@@ -76,8 +76,46 @@ type Run struct {
|
||||
LostReason string // varför banan förlorades (nyckel för UI)
|
||||
FlashKey string // kort meddelande till HUD (textnyckel)
|
||||
FlashAt float64 // < 0 = aldrig
|
||||
|
||||
// Svårighet, poäng och debuffar (M7).
|
||||
Stats Stats // multiplikatorer från profilens debuffar
|
||||
Grumpy int // missnöjda med sittplatsen
|
||||
poisoned bool // otur vid kiosken: matförgiftning efter banan
|
||||
spentStart int // plånbokens Spent vid banstart
|
||||
Score ScoreBreakdown
|
||||
NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt)
|
||||
}
|
||||
|
||||
// ScoreBreakdown är poängen för en vunnen bana.
|
||||
type ScoreBreakdown struct {
|
||||
Time, Money, Meters, Explore, Penalty, Total int
|
||||
}
|
||||
|
||||
// Poängformelns vikter.
|
||||
const (
|
||||
ScoreTimeMax = 500 // par eller snabbare
|
||||
ScoreMoneyMax = 300 // minus 1 per 10 yen spenderade på banan
|
||||
ScoreMetersMax = 200 // sällskapets mätare vid målgång
|
||||
ScoreExploreMax = 100 // utforskad andel
|
||||
PenaltyMissed = 50
|
||||
PenaltyRescan = 10
|
||||
PenaltyGrumpy = 30
|
||||
GrumpyFoodPenalty = 25.0
|
||||
)
|
||||
|
||||
// Fler förlustorsaker.
|
||||
const (
|
||||
LostTimeUp = "result.lost_time_up"
|
||||
LostMissedTrains = "result.lost_missed_trains"
|
||||
)
|
||||
|
||||
// Fler flash-nycklar.
|
||||
const (
|
||||
FlashGrumpy = "hud.grumpy"
|
||||
FlashPoisoned = "hud.poisoned"
|
||||
FlashNoCards = "hud.need_all_cards"
|
||||
)
|
||||
|
||||
// MiniKind är vilket minispel som pågår.
|
||||
type MiniKind int
|
||||
|
||||
@@ -119,11 +157,12 @@ type FacilityState struct {
|
||||
|
||||
// RunOptions styr en banomgång.
|
||||
type RunOptions struct {
|
||||
Speed float64 // rutor per sekund (0 = DefaultSpeed)
|
||||
Dict *lang.Dictionary // nil = tom ordbok
|
||||
Know *lang.Knowledge // nil = ny kunskap
|
||||
Wallet *Wallet // nil = startkassa
|
||||
Seed rng.Seed // omgångens seed (sparas med spelet)
|
||||
Speed float64 // rutor per sekund (0 = DefaultSpeed)
|
||||
Dict *lang.Dictionary // nil = tom ordbok
|
||||
Know *lang.Knowledge // nil = ny kunskap
|
||||
Wallet *Wallet // nil = startkassa
|
||||
Debuffs []Debuff // aktiva debuffar från profilen
|
||||
Seed rng.Seed // omgångens seed (sparas med spelet)
|
||||
}
|
||||
|
||||
// DefaultSpeed är spelarens grundfart i rutor per sekund.
|
||||
@@ -177,7 +216,10 @@ func NewRun(l *level.Level, o RunOptions) *Run {
|
||||
baseSpeed: o.Speed,
|
||||
NoticeAt: -1,
|
||||
Fog: NewFog(l, FogRadius),
|
||||
Stats: CombinedStats(o.Debuffs),
|
||||
spentStart: o.Wallet.Spent,
|
||||
}
|
||||
r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]}
|
||||
r.Fog.Reveal(l, l.SpawnPoint())
|
||||
if r.parSec <= 0 {
|
||||
n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter))
|
||||
@@ -218,9 +260,36 @@ func (r *Run) CanEnter(p level.Point) bool {
|
||||
|
||||
func (r *Run) canPassGate() bool {
|
||||
if r.Level.TicketRequired {
|
||||
return r.Wallet.Ticket != ""
|
||||
if r.Wallet.Ticket == "" {
|
||||
return false
|
||||
}
|
||||
return !r.Level.IndividualCards || r.Wallet.TicketCount >= r.Party.Size()
|
||||
}
|
||||
return r.Wallet.CardUsable() && r.Wallet.Card >= r.Level.FareYen
|
||||
if !r.Wallet.CardUsable() || r.Wallet.Card < r.Level.FareYen {
|
||||
return false
|
||||
}
|
||||
if r.Level.IndividualCards {
|
||||
for _, m := range r.Party.Followers() {
|
||||
if m.Card < r.Level.FareYen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MembersWithoutFare listar följare vars egna kort inte räcker (HUD).
|
||||
func (r *Run) MembersWithoutFare() []*Member {
|
||||
if !r.Level.IndividualCards {
|
||||
return nil
|
||||
}
|
||||
var out []*Member
|
||||
for _, m := range r.Party.Followers() {
|
||||
if m.Card < r.Level.FareYen {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Run) inGate() bool {
|
||||
@@ -240,7 +309,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.Wallet.Drain(dt)
|
||||
before := r.Player.Tile
|
||||
wasGate := r.inGate()
|
||||
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor()
|
||||
r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed]
|
||||
if r.Wallet.Phone && r.Wallet.Battery > 0 {
|
||||
r.Player.Speed *= PhoneSpeedFactor
|
||||
}
|
||||
@@ -260,11 +329,18 @@ func (r *Run) Step(dt float64, want Dir, board bool) {
|
||||
r.stepHelp(dt)
|
||||
r.stepFacility(dt)
|
||||
r.stepReading()
|
||||
r.checkLimits()
|
||||
if r.Player.Tile != before && r.inGate() && !wasGate {
|
||||
r.GatePasses++
|
||||
if !r.Level.TicketRequired {
|
||||
r.Wallet.PayCard(r.Level.FareYen)
|
||||
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance {
|
||||
if r.Level.IndividualCards {
|
||||
for _, m := range r.Party.Followers() {
|
||||
m.Card -= r.Level.FareYen
|
||||
r.Wallet.Spent += r.Level.FareYen
|
||||
}
|
||||
}
|
||||
if r.Wallet.Phone && r.Seed.Derive(fmt.Sprintf("rescan:%d", r.GatePasses)).New().Float64() < PhoneRescanChance/r.Stats[StatLuck] {
|
||||
r.Rescans++
|
||||
r.RescanUntil = r.Time + RescanSec
|
||||
r.flash(FlashRescan)
|
||||
@@ -321,10 +397,22 @@ func (r *Run) startMachine() {
|
||||
}
|
||||
if r.Level.TicketRequired {
|
||||
dest := r.Level.Destination.Get("ja")
|
||||
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), r.Wallet.Cash), MiniTicket
|
||||
var seats []string
|
||||
if r.Level.SeatSelection {
|
||||
seats = r.Party.Names()
|
||||
}
|
||||
cash := r.Wallet.Cash
|
||||
if r.Level.IndividualCards {
|
||||
cash /= r.Party.Size() // alla behöver en biljett: visa vad som går att köpa per person
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTicket(r.miniSeed(), dest, r.stationPool(dest, 4), cash, seats), MiniTicket
|
||||
return
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash), MiniTopUp
|
||||
var members []string
|
||||
if r.Level.IndividualCards {
|
||||
members = r.Party.Names()
|
||||
}
|
||||
r.Mini, r.MiniKind = minigame.NewTopUp(r.miniSeed(), r.Wallet.Cash, members), MiniTopUp
|
||||
}
|
||||
|
||||
// tryBoard försöker kliva på: biljetten måste gälla tåget, fulla tåg kräver
|
||||
@@ -348,7 +436,7 @@ func (r *Run) tryBoard(tr *level.Train) {
|
||||
func (r *Run) startRide() {
|
||||
tr := r.PendingTrain
|
||||
if r.Level.TicketRequired {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare = "", 0 // biljetten är förbrukad
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = "", 0, 0 // biljetterna är förbrukade
|
||||
}
|
||||
dest := r.Level.Destination.Get("ja")
|
||||
r.Mini, r.MiniKind = minigame.NewRide(r.miniSeed(), dest, r.stationPool(dest, 8), r.Level.RideStops, tr.Correct), MiniRide
|
||||
@@ -365,18 +453,43 @@ func (r *Run) StepMini(dt float64, in minigame.Input) {
|
||||
r.Party.Advance(dt, r.Time)
|
||||
r.Mini.Update(in, dt)
|
||||
if !r.Mini.Done() {
|
||||
r.checkLimits()
|
||||
return
|
||||
}
|
||||
m := r.Mini
|
||||
r.Mini, r.MiniKind = nil, MiniNone
|
||||
switch g := m.(type) {
|
||||
case *minigame.TopUp:
|
||||
if g.Success() && r.Wallet.TopUp(g.Amount) {
|
||||
if !g.Success() {
|
||||
break
|
||||
}
|
||||
if g.Member == 0 {
|
||||
if r.Wallet.TopUp(g.Amount) {
|
||||
r.TopUps++
|
||||
}
|
||||
} else if r.Wallet.Cash >= g.Amount {
|
||||
r.Wallet.Cash -= g.Amount
|
||||
r.Party.Members[g.Member].Card += g.Amount
|
||||
r.TopUps++
|
||||
}
|
||||
case *minigame.Ticket:
|
||||
if g.Success() && g.Chosen != nil && r.Wallet.PayCash(g.Chosen.Fare) {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare = g.Chosen.Dest, g.Chosen.Fare
|
||||
if !g.Success() || g.Chosen == nil {
|
||||
break
|
||||
}
|
||||
count := 1
|
||||
if r.Level.IndividualCards {
|
||||
count = r.Party.Size()
|
||||
}
|
||||
if r.Wallet.PayCash(g.Chosen.Fare * count) {
|
||||
r.Wallet.Ticket, r.Wallet.TicketFare, r.Wallet.TicketCount = g.Chosen.Dest, g.Chosen.Fare, count
|
||||
}
|
||||
if len(g.Unhappy) > 0 {
|
||||
r.Grumpy += len(g.Unhappy)
|
||||
for _, i := range g.Unhappy {
|
||||
m := r.Party.Members[i]
|
||||
m.Meters.Food = max(0, m.Meters.Food-GrumpyFoodPenalty)
|
||||
}
|
||||
r.flash(FlashGrumpy)
|
||||
}
|
||||
case *minigame.Crowd:
|
||||
if g.Success() {
|
||||
@@ -432,8 +545,73 @@ func (r *Run) stepFacility(dt float64) {
|
||||
}
|
||||
|
||||
// buyAtKiosk köper mat och dryck åt alla som behöver, betalat med kortet.
|
||||
// Med liten seedad otur blir maten dålig: matförgiftning efter banan.
|
||||
func (r *Run) buyAtKiosk() {
|
||||
r.Served += r.Party.BuyAtKiosk(r.Wallet.PayCard)
|
||||
n := r.Party.BuyAtKiosk(r.Wallet.PayCard)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
r.Served += n
|
||||
if !r.poisoned && r.Seed.Derive(fmt.Sprintf("poison:%d", r.Served)).New().Float64() < FoodPoisonChance/r.Stats[StatLuck] {
|
||||
r.poisoned = true
|
||||
r.flash(FlashPoisoned)
|
||||
}
|
||||
}
|
||||
|
||||
// checkLimits förlorar banan när tidsgränsen eller maxantalet missade tåg passeras.
|
||||
func (r *Run) checkLimits() {
|
||||
if r.Outcome != Ongoing {
|
||||
return
|
||||
}
|
||||
if r.Level.TimeLimitSec > 0 && r.Time > float64(r.Level.TimeLimitSec) {
|
||||
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 {
|
||||
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 {
|
||||
return 0
|
||||
}
|
||||
return max(0, float64(r.Level.TimeLimitSec)-r.Time)
|
||||
}
|
||||
|
||||
// computeScore fyller i poängen för en vunnen bana.
|
||||
func (r *Run) computeScore() {
|
||||
var sc ScoreBreakdown
|
||||
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.Penalty = PenaltyMissed*r.MissedTrains + PenaltyRescan*r.Rescans + PenaltyGrumpy*r.Grumpy
|
||||
sc.Total = max(0, sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty)
|
||||
r.Score = sc
|
||||
}
|
||||
|
||||
// earnedDebuffs räknar ut vilka debuffar banan gav.
|
||||
func (r *Run) earnedDebuffs() []Debuff {
|
||||
var out []Debuff
|
||||
if r.Steps >= SoreFeetSteps {
|
||||
out = append(out, Debuff{SoreFeet, 1})
|
||||
}
|
||||
if r.Time >= TiredSec {
|
||||
out = append(out, Debuff{Tired, 1})
|
||||
}
|
||||
for _, m := range r.Party.Members {
|
||||
if m.Meters.Food <= 0 {
|
||||
out = append(out, Debuff{Hangry, 1})
|
||||
break
|
||||
}
|
||||
}
|
||||
if r.poisoned {
|
||||
out = append(out, Debuff{FoodPoisoning, FoodPoisonLevels})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FacilityProgress är 0..1 för ett pågående toalettbesök.
|
||||
@@ -472,12 +650,14 @@ 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.NewDebuffs = r.earnedDebuffs()
|
||||
if !won {
|
||||
r.Outcome = Lost
|
||||
r.LostReason = reason
|
||||
return
|
||||
}
|
||||
r.Outcome = Won
|
||||
r.computeScore()
|
||||
r.Stars = r.StarsFor(r.Time)
|
||||
r.PointsEarned = r.Know.Award(r.Stars)
|
||||
if r.Stars >= lang.StarsToLearnDestination {
|
||||
@@ -515,7 +695,7 @@ func (r *Run) stepReading() {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
if roll.Float64() >= lang.ReadChance {
|
||||
if roll.Float64() >= lang.ReadChance*r.Stats[StatFocus] {
|
||||
continue
|
||||
}
|
||||
if r.Know.AddProgress(w) {
|
||||
@@ -547,7 +727,7 @@ func (r *Run) AskHelp() bool {
|
||||
if sg == nil || r.Help.Active || r.Revealed[r.Player.Tile] || r.Outcome != Ongoing {
|
||||
return false
|
||||
}
|
||||
secs := lang.HelpSeconds(r.Know.Level())
|
||||
secs := lang.HelpSeconds(r.Know.Level()) / r.Stats[StatFocus]
|
||||
r.Help = HelpState{Active: true, At: r.Player.Tile, Remaining: secs, Total: secs}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -12,6 +12,19 @@ import (
|
||||
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng"
|
||||
)
|
||||
|
||||
// fillSeats placerar sällskapet på första lediga platser (sittplatssteget).
|
||||
func fillSeats(r *Run, tk *minigame.Ticket) {
|
||||
for r.Mini != nil && tk.Stage == minigame.TicketPickSeat {
|
||||
for tk.SeatTaken(tk.Seat) {
|
||||
r.StepMini(frame, minigame.Input{MoveX: 1})
|
||||
if tk.Seat.Col == 0 {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
}
|
||||
}
|
||||
|
||||
// ride spelar tågresan: kliver av när tåget står vid resmålet. På fel tåg
|
||||
// finns inget resmål, så resan går till slutstationen.
|
||||
func ride(t *testing.T, r *Run) {
|
||||
@@ -487,7 +500,7 @@ func TestTopUpFlowAtMachine(t *testing.T) {
|
||||
}
|
||||
cash := r.Wallet.Cash
|
||||
for g.Buttons[g.Cursor] != "チャージ" {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
r.StepMini(frame, minigame.Input{MoveX: 1})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
r.StepMini(frame, minigame.Input{Confirm: true}) // första beloppet (1000)
|
||||
@@ -537,6 +550,7 @@ func TestTicketLevelFlow(t *testing.T) {
|
||||
}
|
||||
wrong := tk.Options[tk.Cursor]
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
fillSeats(r, tk) // 002 har sittplatsval
|
||||
if r.Wallet.Ticket != wrong.Dest || r.Wallet.Cash != StartCashYen-wrong.Fare {
|
||||
t.Fatalf("biljett=%q cash=%d", r.Wallet.Ticket, r.Wallet.Cash)
|
||||
}
|
||||
@@ -556,6 +570,7 @@ func TestTicketLevelFlow(t *testing.T) {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
fillSeats(r, tk)
|
||||
if r.Wallet.Ticket != "渋谷" {
|
||||
t.Fatalf("biljett=%q", r.Wallet.Ticket)
|
||||
}
|
||||
@@ -664,3 +679,225 @@ func TestPhoneSpeedRescanAndBattery(t *testing.T) {
|
||||
t.Fatal("ingen fartbuff med dött batteri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeLimitAndMissedTrainsLose(t *testing.T) {
|
||||
l := loadLevel(t, "002-shinjuku")
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||
if r.TimeLeft() != float64(l.TimeLimitSec) {
|
||||
t.Fatalf("TimeLeft=%.0f", r.TimeLeft())
|
||||
}
|
||||
r.Time = float64(l.TimeLimitSec) - 0.5
|
||||
for i := 0; i < 60 && r.Outcome == Ongoing; i++ {
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
if r.Outcome != Lost || r.LostReason != LostTimeUp || r.Boarded != nil {
|
||||
t.Fatalf("tidsgräns: outcome=%v reason=%q", r.Outcome, r.LostReason)
|
||||
}
|
||||
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||
r2.MissedTrains = l.MaxMissedTrains + 1
|
||||
r2.Step(frame, None, false)
|
||||
if r2.Outcome != Lost || r2.LostReason != LostMissedTrains {
|
||||
t.Fatalf("missade tåg: outcome=%v reason=%q", r2.Outcome, r2.LostReason)
|
||||
}
|
||||
// Bana 001 har inga gränser.
|
||||
r3 := NewRun(loadLevel(t, "001-flygplatsen"), RunOptions{Seed: rng.FromInt(1)})
|
||||
r3.Time, r3.MissedTrains = 99999, 99
|
||||
r3.Step(frame, None, false)
|
||||
if r3.Outcome != Ongoing || r3.TimeLeft() != 0 {
|
||||
t.Fatal("utan gränser förlorar man inte på tid eller missade tåg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreBreakdown(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
walk(t, r, l.Path(r.Player.Tile, l.CorrectPlatform(), r.CanEnter))
|
||||
waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
ride(t, r)
|
||||
sc := r.Score
|
||||
if r.Outcome != Won || sc.Total <= 0 || sc.Total != sc.Time+sc.Money+sc.Meters+sc.Explore-sc.Penalty {
|
||||
t.Fatalf("score=%+v", sc)
|
||||
}
|
||||
if sc.Time != ScoreTimeMax || sc.Money != ScoreMoneyMax-l.FareYen/10 || sc.Penalty != 0 {
|
||||
t.Fatalf("snabb, billig vinst: %+v", sc)
|
||||
}
|
||||
if sc.Meters <= 0 || sc.Meters > ScoreMetersMax || sc.Explore <= 0 || sc.Explore > ScoreExploreMax {
|
||||
t.Fatalf("mätare/utforskat: %+v", sc)
|
||||
}
|
||||
// Avdrag räknas.
|
||||
r2 := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(2)})
|
||||
r2.MissedTrains, r2.Rescans, r2.Grumpy = 1, 2, 1
|
||||
r2.Time = 10 * r2.ParSec()
|
||||
r2.computeScore()
|
||||
if r2.Score.Penalty != PenaltyMissed+2*PenaltyRescan+PenaltyGrumpy || r2.Score.Time != ScoreTimeMax/10 {
|
||||
t.Fatalf("avdrag: %+v", r2.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebuffsAffectRunAndAreEarned(t *testing.T) {
|
||||
l := loadLevel(t, "001-flygplatsen")
|
||||
r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Debuffs: []Debuff{{Blisters, 1}, {FoodPoisoning, 1}, {Hangry, 1}}})
|
||||
r.Party.nextEventAt = 1e9
|
||||
r.Step(frame, Right, false)
|
||||
if math.Abs(r.Player.Speed-DefaultSpeed*0.8) > 1e-9 || r.Party.Rates.Toilet != 2 || r.Party.Rates.Thirst != 1.2 {
|
||||
t.Fatalf("speed=%.2f rates=%+v", r.Player.Speed, r.Party.Rates)
|
||||
}
|
||||
r.Step(frame, None, false)
|
||||
for r.Player.Moving() {
|
||||
r.Step(frame, None, false)
|
||||
}
|
||||
walk(t, r, l.Path(r.Player.Tile, l.Find(level.Sign)[1], r.CanEnter))
|
||||
r.AskHelp()
|
||||
if want := lang.HelpSeconds(0) / 0.7; math.Abs(r.Help.Total-want) > 1e-9 {
|
||||
t.Fatalf("hjälptid med dåligt fokus %.2f, vill ha %.2f", r.Help.Total, want)
|
||||
}
|
||||
// Intjänade debuffar: många steg, lång tid, tom matmätare.
|
||||
r.Steps, r.Time = SoreFeetSteps, TiredSec
|
||||
r.Party.Members[1].Meters.Food = 0
|
||||
r.finish(nil, false, LostTimeUp)
|
||||
kinds := map[DebuffKind]bool{}
|
||||
for _, d := range r.NewDebuffs {
|
||||
kinds[d.Kind] = true
|
||||
}
|
||||
if !kinds[SoreFeet] || !kinds[Tired] || !kinds[Hangry] || kinds[FoodPoisoning] {
|
||||
t.Fatalf("intjänade=%v", r.NewDebuffs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndividualCardsAtGateAndMachine(t *testing.T) {
|
||||
l := loadLevel(t, "003-shibuya")
|
||||
if !l.IndividualCards || !l.TicketRequired || !l.SeatSelection || l.Party != 4 {
|
||||
t.Fatal("003 ska ha egna kort, biljetter, sittplatser och fyra i sällskapet")
|
||||
}
|
||||
// Egna kort på en kortbana (utan biljettkrav): alla måste ha saldo.
|
||||
l1 := loadLevel(t, "001-flygplatsen")
|
||||
l1.IndividualCards = true
|
||||
r := NewRun(l1, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(1)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
gate := l1.Find(level.Gate)[0]
|
||||
anna := r.Party.Members[1]
|
||||
if anna.Card != MemberCardYen || !r.CanEnter(gate) {
|
||||
t.Fatalf("Annas kort=%d canEnter=%v", anna.Card, r.CanEnter(gate))
|
||||
}
|
||||
anna.Card = 0
|
||||
if r.CanEnter(gate) || len(r.MembersWithoutFare()) != 1 {
|
||||
t.Fatal("utan saldo på Annas kort stoppar spärren alla")
|
||||
}
|
||||
// Fyll på Annas kort i automaten (personval först).
|
||||
walk(t, r, l1.Path(r.Player.Tile, l1.Find(level.TicketMachine)[0], r.CanEnter))
|
||||
r.Step(frame, None, true)
|
||||
g := r.Mini.(*minigame.TopUp)
|
||||
if g.Stage != minigame.TopUpPickMember || len(g.Members) != 2 {
|
||||
t.Fatalf("stage=%v members=%v", g.Stage, g.Members)
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
for g.Buttons[g.Cursor] != "チャージ" {
|
||||
r.StepMini(frame, minigame.Input{MoveX: 1})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
cash := r.Wallet.Cash
|
||||
for i := 0; i < 600 && r.Mini != nil; i++ {
|
||||
r.StepMini(frame, minigame.Input{Hold: true})
|
||||
}
|
||||
if anna.Card != 1000 || r.Wallet.Cash != cash-1000 || r.Wallet.Card != StartCardYen {
|
||||
t.Fatalf("Annas kort=%d cash=%d eget=%d", anna.Card, r.Wallet.Cash, r.Wallet.Card)
|
||||
}
|
||||
// Spärren drar från allas kort.
|
||||
before := r.Wallet.Spent
|
||||
path := l1.Path(r.Player.Tile, gate, r.CanEnter)
|
||||
for i, p := range path {
|
||||
if l1.At(p.X, p.Y) == level.Gate {
|
||||
path = path[:i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
walk(t, r, path)
|
||||
if anna.Card != 1000-l1.FareYen || r.Wallet.Card != StartCardYen-l1.FareYen || r.Wallet.Spent != before+2*l1.FareYen {
|
||||
t.Fatalf("efter spärren: anna=%d egen=%d spent=%d", anna.Card, r.Wallet.Card, r.Wallet.Spent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevel003Flow: biljettbana med egna kort och sittplatser – alla
|
||||
// behöver biljett, sällskapet placeras, och boten vinner.
|
||||
func TestLevel003Flow(t *testing.T) {
|
||||
l := loadLevel(t, "003-shibuya")
|
||||
r := NewRun(l, RunOptions{Dict: loadDict(t), Seed: rng.FromInt(21)})
|
||||
r.Party.nextEventAt = 1e9
|
||||
machine := l.Find(level.TicketMachine)[0]
|
||||
walk(t, r, l.Path(r.Player.Tile, machine, r.CanEnter))
|
||||
r.Step(frame, None, true)
|
||||
tk := r.Mini.(*minigame.Ticket)
|
||||
if len(tk.Members) != 4 || tk.Cash != StartCashYen/4 {
|
||||
t.Fatalf("members=%v cash=%d", tk.Members, tk.Cash)
|
||||
}
|
||||
for tk.Options[tk.Cursor].Dest != "池袋" {
|
||||
r.StepMini(frame, minigame.Input{Move: 1})
|
||||
}
|
||||
fare := tk.Options[tk.Cursor].Fare
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
if tk.Stage != minigame.TicketPickSeat {
|
||||
t.Fatal("sittplatsval ska följa")
|
||||
}
|
||||
// Placera alla så att önskemålen uppfylls när det går: prova platser tills nöjd.
|
||||
for cur := 0; r.Mini != nil; cur++ {
|
||||
best := minigame.Seat{Row: -1}
|
||||
for row := 0; row < minigame.SeatRows && best.Row < 0; row++ {
|
||||
for col := 0; col < minigame.SeatCols; col++ {
|
||||
cand := minigame.Seat{Row: row, Col: col}
|
||||
if tk.SeatTaken(cand) {
|
||||
continue
|
||||
}
|
||||
trial := append([]minigame.Seat(nil), tk.Assigned...)
|
||||
trial[cur] = cand
|
||||
ok := true
|
||||
for _, p := range tk.Prefs {
|
||||
if p.Member == cur && !p.Satisfied(trial) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
best = cand
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.Row < 0 {
|
||||
t.Fatal("ingen ledig plats")
|
||||
}
|
||||
for tk.Seat != best {
|
||||
in := minigame.Input{}
|
||||
if tk.Seat.Row != best.Row {
|
||||
in.Move = 1
|
||||
} else {
|
||||
in.MoveX = 1
|
||||
}
|
||||
r.StepMini(frame, in)
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
}
|
||||
if r.Wallet.TicketCount != 4 || r.Wallet.Ticket != "池袋" || r.Wallet.Cash != StartCashYen-4*fare {
|
||||
t.Fatalf("biljetter=%d dest=%q cash=%d", r.Wallet.TicketCount, r.Wallet.Ticket, r.Wallet.Cash)
|
||||
}
|
||||
// Den giriga placeringen kan missa "bredvid X" som placeras senare; det
|
||||
// är tillåtet men ska då synas som missnöje.
|
||||
if r.Grumpy > 0 && r.FlashKey != FlashGrumpy {
|
||||
t.Fatal("missnöje ska flashas")
|
||||
}
|
||||
goal := l.CorrectPlatform()
|
||||
walk(t, r, l.Path(r.Player.Tile, goal, r.CanEnter))
|
||||
waitForTrain(t, r)
|
||||
r.Step(frame, None, true)
|
||||
cr := r.Mini.(*minigame.Crowd)
|
||||
for i := 0; i < 600 && !cr.InZone(); i++ {
|
||||
r.StepMini(frame, minigame.Input{})
|
||||
}
|
||||
r.StepMini(frame, minigame.Input{Confirm: true})
|
||||
ride(t, r)
|
||||
if r.Outcome != Won || r.Wallet.TicketCount != 0 {
|
||||
t.Fatalf("outcome=%v reason=%q tickets=%d", r.Outcome, r.LostReason, r.Wallet.TicketCount)
|
||||
}
|
||||
}
|
||||
|
||||
121
internal/sim/stats.go
Normal file
121
internal/sim/stats.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package sim
|
||||
|
||||
// Stats är spelarens multiplikatorer: 1,0 = normalt. Debuffar (och i M8
|
||||
// items/skills) ändrar dem.
|
||||
type Stats [5]float64
|
||||
|
||||
// Statindex.
|
||||
const (
|
||||
StatSpeed = iota // gångfart
|
||||
StatThirst // hur fort dryckesmätaren sjunker (>1 = fortare)
|
||||
StatFocus // chans att lära av skyltar, hjälptid (>1 = bättre)
|
||||
StatToilet // hur fort toamätaren sjunker (>1 = fortare)
|
||||
StatLuck // omskanning, trängsel (>1 = bättre; items i M8)
|
||||
)
|
||||
|
||||
// DefaultStats är allt på 1,0.
|
||||
func DefaultStats() Stats { return Stats{1, 1, 1, 1, 1} }
|
||||
|
||||
// DebuffKind är en typ av debuff (textnyckel: "debuff.<kind>").
|
||||
type DebuffKind string
|
||||
|
||||
// Debuffar. De fyra första uppstår av spelandet (M7), resten delas ut av
|
||||
// sidouppdrag (M10) och varar 1–4 banor.
|
||||
const (
|
||||
SoreFeet DebuffKind = "sore_feet" // många steg
|
||||
Tired DebuffKind = "tired" // lång bana
|
||||
Hangry DebuffKind = "hangry" // tom matmätare
|
||||
FoodPoisoning DebuffKind = "food_poisoning" // otur vid kiosken
|
||||
DOMS DebuffKind = "doms" // träningsvärk
|
||||
Blisters DebuffKind = "blisters" // skoskav
|
||||
HeavyBag DebuffKind = "heavy_bag" // köpt något tungt/stort
|
||||
Stuffed DebuffKind = "stuffed" // proppmätt
|
||||
Sweaty DebuffKind = "sweaty" // svettig och varm
|
||||
)
|
||||
|
||||
// DebuffEffects är vad varje debuff gör med statsen.
|
||||
var DebuffEffects = map[DebuffKind]Stats{
|
||||
SoreFeet: {0.85, 1, 1, 1, 1},
|
||||
Tired: {0.9, 1, 0.8, 1, 1},
|
||||
Hangry: {1, 1.2, 0.7, 1, 1},
|
||||
FoodPoisoning: {1, 1, 1, 2.0, 1},
|
||||
DOMS: {0.9, 1, 1, 1, 1},
|
||||
Blisters: {0.8, 1, 1, 1, 1},
|
||||
HeavyBag: {0.9, 1.2, 1, 1, 1},
|
||||
Stuffed: {0.9, 1, 1, 1.3, 1},
|
||||
Sweaty: {1, 1.5, 0.9, 1, 1},
|
||||
}
|
||||
|
||||
// Utlösare för de spelgenererade debuffarna.
|
||||
const (
|
||||
SoreFeetSteps = 150
|
||||
TiredSec = 300.0
|
||||
FoodPoisonChance = 0.05
|
||||
FoodPoisonLevels = 2
|
||||
)
|
||||
|
||||
// Debuff är en aktiv debuff med återstående antal banor.
|
||||
type Debuff struct {
|
||||
Kind DebuffKind `json:"kind"`
|
||||
LevelsLeft int `json:"levelsLeft"`
|
||||
}
|
||||
|
||||
// CombinedStats multiplicerar ihop effekterna av alla debuffar.
|
||||
func CombinedStats(debuffs []Debuff) Stats {
|
||||
st := DefaultStats()
|
||||
for _, d := range debuffs {
|
||||
if e, ok := DebuffEffects[d.Kind]; ok {
|
||||
for i := range st {
|
||||
st[i] *= e[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Profile är det som följer spelaren mellan banor och inte hör till
|
||||
// kunskap eller plånbok: aktiva debuffar (items och skills i M8).
|
||||
type Profile struct {
|
||||
Debuffs []Debuff `json:"debuffs"`
|
||||
}
|
||||
|
||||
// Add lägger till en debuff; finns den redan förlängs den till det längsta.
|
||||
func (p *Profile) Add(kind DebuffKind, levels int) {
|
||||
for i := range p.Debuffs {
|
||||
if p.Debuffs[i].Kind == kind {
|
||||
p.Debuffs[i].LevelsLeft = max(p.Debuffs[i].LevelsLeft, levels)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Debuffs = append(p.Debuffs, Debuff{Kind: kind, LevelsLeft: levels})
|
||||
}
|
||||
|
||||
// Has rapporterar om debuffen är aktiv.
|
||||
func (p *Profile) Has(kind DebuffKind) bool {
|
||||
for _, d := range p.Debuffs {
|
||||
if d.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EndLevel räknar ned alla debuffar en bana och tar bort de som gått ut.
|
||||
// Returnerar de som försvann.
|
||||
func (p *Profile) EndLevel() []DebuffKind {
|
||||
var expired []DebuffKind
|
||||
kept := p.Debuffs[:0]
|
||||
for _, d := range p.Debuffs {
|
||||
d.LevelsLeft--
|
||||
if d.LevelsLeft <= 0 {
|
||||
expired = append(expired, d.Kind)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, d)
|
||||
}
|
||||
p.Debuffs = kept
|
||||
return expired
|
||||
}
|
||||
|
||||
// Stats är de sammanlagda multiplikatorerna just nu.
|
||||
func (p *Profile) Stats() Stats { return CombinedStats(p.Debuffs) }
|
||||
45
internal/sim/stats_test.go
Normal file
45
internal/sim/stats_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package sim
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDebuffEffectsAndProfile(t *testing.T) {
|
||||
if DefaultStats() != (Stats{1, 1, 1, 1, 1}) {
|
||||
t.Fatal("default")
|
||||
}
|
||||
for kind, e := range DebuffEffects {
|
||||
changed := false
|
||||
for i := range e {
|
||||
if e[i] != 1 {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
t.Errorf("%s påverkar inget", kind)
|
||||
}
|
||||
}
|
||||
st := CombinedStats([]Debuff{{SoreFeet, 1}, {Blisters, 1}})
|
||||
if st[StatSpeed] < 0.67 || st[StatSpeed] > 0.69 || st[StatThirst] != 1 {
|
||||
t.Fatalf("kombinerad fart %.3f", st[StatSpeed])
|
||||
}
|
||||
|
||||
var p Profile
|
||||
p.Add(Tired, 1)
|
||||
p.Add(DOMS, 3)
|
||||
p.Add(Tired, 2) // förlängs, inte dubbleras
|
||||
if len(p.Debuffs) != 2 || !p.Has(Tired) || p.Debuffs[0].LevelsLeft != 2 {
|
||||
t.Fatalf("debuffs=%v", p.Debuffs)
|
||||
}
|
||||
if p.Stats()[StatSpeed] != 0.9*0.9 {
|
||||
t.Fatalf("stats=%v", p.Stats())
|
||||
}
|
||||
if exp := p.EndLevel(); len(exp) != 0 || len(p.Debuffs) != 2 {
|
||||
t.Fatalf("efter bana 1: exp=%v debuffs=%v", exp, p.Debuffs)
|
||||
}
|
||||
if exp := p.EndLevel(); len(exp) != 1 || exp[0] != Tired || len(p.Debuffs) != 1 || p.Debuffs[0].Kind != DOMS {
|
||||
t.Fatalf("efter bana 2: exp=%v debuffs=%v", exp, p.Debuffs)
|
||||
}
|
||||
p.EndLevel()
|
||||
if len(p.Debuffs) != 0 || p.Has(DOMS) {
|
||||
t.Fatal("DOMS ska ha gått ut efter tre banor")
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@ package sim
|
||||
// Wallet är spelarens pengar och biljetter. Följer med mellan banor
|
||||
// (sparas i M8).
|
||||
type Wallet struct {
|
||||
Card int `json:"card"` // IC-kortets saldo (yen)
|
||||
Cash int `json:"cash"` // kontanter (yen)
|
||||
Phone bool `json:"phone"` // kortet ligger i telefonen
|
||||
Battery float64 `json:"battery"` // telefonens batteri 0..100
|
||||
Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen
|
||||
TicketFare int `json:"ticketFare"` // vad biljetten kostade
|
||||
Spent int `json:"spent"` // totalt spenderat (poäng i M7)
|
||||
Card int `json:"card"` // IC-kortets saldo (yen)
|
||||
Cash int `json:"cash"` // kontanter (yen)
|
||||
Phone bool `json:"phone"` // kortet ligger i telefonen
|
||||
Battery float64 `json:"battery"` // telefonens batteri 0..100
|
||||
Ticket string `json:"ticket"` // köpt pappersbiljett: resmålets namn (ja), "" = ingen
|
||||
TicketFare int `json:"ticketFare"` // vad biljetten kostade
|
||||
TicketCount int `json:"ticketCount"` // antal biljetter (egna kort: en per person)
|
||||
Spent int `json:"spent"` // totalt spenderat (poäng i M7)
|
||||
}
|
||||
|
||||
// Pengar och telefon.
|
||||
|
||||
Reference in New Issue
Block a user