Files
metro-puzzle-jp/internal/level/level_test.go
claude b2995d4ca9
All checks were successful
release / build-release (push) Successful in 1m37s
M7: svårighet, tid, poäng, debuffar, egna kort och sittplatsval
- 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>
2026-10-24 10:18:17 +02:00

324 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package level
import (
"math"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res"
)
const sample = `{
"id": "test",
"name": {"sv": "Test"},
"difficulty": 1,
"destination": {"sv": "Mål"},
"grid": [
"#######",
"#S..#P#",
"#.#.#.#",
"#.#...#",
"#######"
],
"trains": [{"platform": "P", "line": {"en": "A"}, "destination": {"en": "B"}, "correct": true}]
}`
func mustParse(t *testing.T, s string) *Level {
t.Helper()
l, err := Parse([]byte(s))
if err != nil {
t.Fatal(err)
}
return l
}
func TestParseAndSolve(t *testing.T) {
l := mustParse(t, sample)
if l.Width() != 7 || l.Height() != 5 {
t.Fatalf("storlek %dx%d", l.Width(), l.Height())
}
if l.SpawnPoint() != (Point{1, 1}) {
t.Fatalf("spawn %v", l.SpawnPoint())
}
if !l.Solvable() {
t.Fatal("banan ska vara lösbar")
}
if u := l.Unreachable(); len(u) != 0 {
t.Fatalf("oväntat onåbara rutor: %v", u)
}
}
func TestUnsolvableWhenWalledOff(t *testing.T) {
l := mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.#.#"`, 1))
if l.Solvable() {
t.Fatal("banan ska INTE vara lösbar när perrongen är inmurad")
}
if u := l.Unreachable(); len(u) != 1 || u[0] != (Point{5, 1}) {
t.Fatalf("Unreachable = %v, vill ha [(5,1)]", u)
}
}
func TestOutsideGridIsWall(t *testing.T) {
l := mustParse(t, sample)
if l.At(-1, 0).Passable() || l.At(0, 99).Passable() {
t.Fatal("utanför kartan ska räknas som vägg")
}
}
func TestValidationErrors(t *testing.T) {
cases := map[string]string{
"ojämn rad": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#.##"`, 1),
"okänt tecken": strings.Replace(sample, `"#.#.#.#"`, `"#.#.#X#"`, 1),
"två starter": strings.Replace(sample, `"#S..#P#"`, `"#S.S#P#"`, 1),
"ingen start": strings.Replace(sample, `"#S..#P#"`, `"#...#P#"`, 1),
"ingen perrong": strings.Replace(sample, `"#S..#P#"`, `"#S..#.#"`, 1),
"inget rätt tåg": strings.Replace(sample, `"correct": true`, `"correct": false`, 1),
"tåg vid fel perrong": strings.Replace(sample, `"platform": "P"`, `"platform": "p"`, 1),
"dwell >= interval": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"intervalSec": 10, "dwellSec": 10}`, 1),
"negativ offset": strings.Replace(sample, `"correct": true`, `"correct": true, "schedule": {"offsetSec": -1}`, 1),
"skylt utanför i-ruta": strings.Replace(sample, `"trains"`, `"signs": [{"x": 1, "y": 1, "text": {"sv": "x"}}], "trains"`, 1),
"i-ruta utan skylt": strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
"skylt utan text": strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1), `"trains"`, `"signs": [{"x": 2, "y": 1, "text": {}}], "trains"`, 1),
"saknar id": strings.Replace(sample, `"id": "test"`, `"id": ""`, 1),
"trasig json": "{",
}
for name, src := range cases {
if _, err := Parse([]byte(src)); err == nil {
t.Errorf("%s: förväntade fel", name)
}
}
}
func TestTextFallback(t *testing.T) {
tx := Text{"en": "Exit", "ja": "出口"}
if tx.Get("ja") != "出口" || tx.Get("sv") != "Exit" {
t.Fatal("fel fallback")
}
if (Text{"ja": "x"}).Get("sv") != "x" {
t.Fatal("fallback till godtyckligt språk saknas")
}
if (Text{}).Get("sv") != "" {
t.Fatal("tom text ska ge tom sträng")
}
}
// TestAllShippedLevels är ett datadrivet test: varje bana under
// resources/data/levels måste parsa, validera och vara lösbar.
// Lägger man till en bana som inte går att klara faller bygget här.
func TestAllShippedLevels(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
if err != nil {
t.Fatal(err)
}
if len(files) == 0 {
t.Fatalf("inga banor i %s", dir)
}
for _, f := range files {
t.Run(filepath.Base(f), func(t *testing.T) {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
l, err := Parse(b)
if err != nil {
t.Fatal(err)
}
if want := strings.TrimSuffix(filepath.Base(f), ".json"); l.ID != want {
t.Errorf("id %q matchar inte filnamnet %q", l.ID, want)
}
if !l.Solvable() {
t.Error("banan går inte att klara från start")
}
if u := l.Unreachable(); len(u) > 0 {
t.Errorf("onåbara interaktiva rutor: %v", u)
}
if u := l.UngatedPlatforms(); len(u) > 0 {
t.Errorf("perronger som nås utan att passera en spärr: %v – spärrarna måste vara flaskhalsar", u)
}
for i, tr := range l.Trains {
if tr.Track == "" {
t.Errorf("tåg %d saknar spårnummer", i)
}
}
if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) {
t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)")
}
for _, sg := range l.Signs {
if sg.Text["sv"] == "" || sg.Text["en"] == "" {
t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y)
}
}
})
}
}
func TestListAndLoadFile(t *testing.T) {
dir, err := res.Path("data", "levels")
if err != nil {
t.Fatal(err)
}
ids, err := List(dir)
if err != nil || len(ids) == 0 {
t.Fatalf("ids=%v err=%v", ids, err)
}
if !sort.StringsAreSorted(ids) {
t.Fatalf("inte sorterade: %v", ids)
}
if ids[0] != "001-flygplatsen" {
t.Fatalf("första banan ska vara 001-flygplatsen, fick %q", ids[0])
}
l, err := LoadFile(filepath.Join(dir, ids[0]+".json"))
if err != nil {
t.Fatal(err)
}
if tr := l.TrainAt(Platform); tr == nil || !tr.Correct {
t.Fatal("TrainAt(P) ska ge det rätta tåget")
}
if tr := l.TrainAt(WrongPlatform); tr == nil || tr.Correct {
t.Fatal("TrainAt(p) ska ge ett fel tåg")
}
if l.TrainAt(Wall) != nil {
t.Fatal("TrainAt(#) ska vara nil")
}
if _, err := LoadFile(filepath.Join(dir, "finns-inte.json")); err == nil {
t.Fatal("saknad fil ska ge fel")
}
}
func TestScheduleStatus(t *testing.T) {
s := Schedule{OffsetSec: 5, IntervalSec: 30, DwellSec: 10}
cases := []struct {
t float64
at bool
secs float64
}{
{0, false, 5}, {5, true, 10}, {14.9, true, 0.1}, {15, false, 20}, {34.9, false, 0.1}, {35, true, 10}, {70, true, 5}, {80, false, 15},
}
for _, c := range cases {
at, secs := s.Status(c.t)
if at != c.at || math.Abs(secs-c.secs) > 1e-9 {
t.Errorf("t=%v: at=%v secs=%.2f, vill ha at=%v secs=%.2f", c.t, at, secs, c.at, c.secs)
}
}
}
func TestScheduleAndFareDefaults(t *testing.T) {
l := mustParse(t, sample)
sc := l.Trains[0].Schedule
if sc.IntervalSec != DefaultIntervalSec || sc.DwellSec != DefaultDwellSec || sc.OffsetSec != 0 {
t.Fatalf("standardtidtabell saknas: %+v", sc)
}
if l.FareYen != DefaultFareYen {
t.Fatalf("standardavgift saknas: %d", l.FareYen)
}
}
func TestSignAt(t *testing.T) {
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#Si.#P#"`, 1),
`"trains"`, `"signs": [{"x": 2, "y": 1, "text": {"sv": "Hej"}}], "trains"`, 1)
l := mustParse(t, src)
if sg := l.SignAt(Point{2, 1}); sg == nil || sg.Text.Get("sv") != "Hej" {
t.Fatalf("SignAt = %v", sg)
}
if l.SignAt(Point{1, 1}) != nil {
t.Fatal("ingen skylt på startrutan")
}
}
func TestPath(t *testing.T) {
l := mustParse(t, sample)
path := l.Path(l.SpawnPoint(), Point{5, 1}, l.CanEnter)
// S(1,1) -> (1,2) -> (1,3) -> (2,3) -> (3,3) -> (4,3) -> (5,3) -> (5,2) -> (5,1)
if len(path) != 9 || path[0] != (Point{1, 1}) || path[len(path)-1] != (Point{5, 1}) {
t.Fatalf("path = %v", path)
}
for i := 1; i < len(path); i++ {
dx, dy := path[i].X-path[i-1].X, path[i].Y-path[i-1].Y
if dx*dx+dy*dy != 1 {
t.Fatalf("steg %d är inte en grannruta: %v -> %v", i, path[i-1], path[i])
}
}
if l.Path(l.SpawnPoint(), Point{0, 0}, l.CanEnter) != nil {
t.Fatal("väg in i en vägg ska vara nil")
}
if p := l.Path(l.SpawnPoint(), l.SpawnPoint(), l.CanEnter); len(p) != 1 {
t.Fatalf("väg till sig själv = %v", p)
}
}
func TestUngatedPlatforms(t *testing.T) {
// Perrong utan spärr framför -> rapporteras.
l := mustParse(t, sample)
if u := l.UngatedPlatforms(); len(u) != 1 || u[0] != (Point{5, 1}) {
t.Fatalf("UngatedPlatforms = %v, vill ha [(5,1)]", u)
}
// Spärr i enda passagen -> inget rapporteras, men banan är fortfarande lösbar.
l = mustParse(t, strings.Replace(sample, `"#.#...#"`, `"#.#.G.#"`, 1))
if u := l.UngatedPlatforms(); len(u) != 0 {
t.Fatalf("UngatedPlatforms = %v, vill ha inga", u)
}
if !l.Solvable() {
t.Fatal("banan ska vara lösbar med spärren passerbar")
}
}
func TestPartyAndParDefaults(t *testing.T) {
l := mustParse(t, sample)
if l.Party != 1 || l.ParSec != 0 {
t.Fatalf("party=%d par=%d", l.Party, l.ParSec)
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "party": 9`, 1))); err == nil {
t.Fatal("för stort sällskap ska ge fel")
}
if l.CorrectPlatform() != (Point{5, 1}) {
t.Fatalf("CorrectPlatform = %v", l.CorrectPlatform())
}
if got := l.Trains[0].BoardText(); got != "番線 A B方面" {
t.Fatalf("BoardText = %q", got)
}
l2 := mustParse(t, strings.Replace(sample, `"#S..#P#"`, `"#SW.KP#"`, 1))
if l2.At(2, 1) != Toilet || l2.At(4, 1) != Kiosk || !l2.Solvable() {
t.Fatal("W och K ska vara passerbara rutor")
}
}
func TestTicketAndRideFields(t *testing.T) {
l := mustParse(t, sample)
if l.RideStops != DefaultRideStops || l.TicketRequired || l.Trains[0].Crowded {
t.Fatalf("standard: stops=%d ticket=%v crowded=%v", l.RideStops, l.TicketRequired, l.Trains[0].Crowded)
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true`, 1))); err == nil {
t.Fatal("biljettkrav utan automat ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "rideStops": 1`, 1))); err == nil {
t.Fatal("för få stopp ska ge fel")
}
ok := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`, `"difficulty": 1, "ticketRequired": true, "rideStops": 4`, 1)
l2 := mustParse(t, ok)
if !l2.TicketRequired || l2.RideStops != 4 {
t.Fatal("fälten ska läsas")
}
}
func TestDifficultySettings(t *testing.T) {
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "seatSelection": true`, 1))); err == nil {
t.Fatal("sittplatsval utan biljettbana ska ge fel")
}
if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "maxMissedTrains": -1`, 1))); err == nil {
t.Fatal("negativt maxantal ska ge fel")
}
src := strings.Replace(strings.Replace(sample, `"#S..#P#"`, `"#ST.#P#"`, 1), `"difficulty": 1`,
`"difficulty": 3, "ticketRequired": true, "seatSelection": true, "individualCards": true, "maxMissedTrains": 2, "timeLimitSec": 300`, 1)
l := mustParse(t, src)
if !l.SeatSelection || !l.IndividualCards || l.MaxMissedTrains != 2 || l.TimeLimitSec != 300 {
t.Fatal("inställningarna ska läsas")
}
}