M0: skelett, byggkedja och dokumentation för JP Subway Pussel

- Go 1.27 + Ebitengine v2.10, intern upplösning 320x180 med heltalsskalning
- internal/res (resursuppslag), internal/rng (seedad slump), internal/level
  (banformat, validering, BFS-lösbarhet) med tester; internal/archtest
  kontrollerar att bara presentationslagret beror på Ebitengine
- tools/build: debug -> build/debug/, release -> build/release/<os>-<arch>/
  med resources/ bredvid binären; Windows korsbyggs utan C-verktyg
- VS Code tasks (build/test/release) och launch (dlv)
- Första banan 001-flygplatsen, sv/en-texter, skyltordbok, placeholder-sprite
- doc/: spelbeskrivning (renskriven), mål och tester, roadmap med teknikval
  (M0–M12), plan.md; README med bygg- och beroendeinformation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:52:10 +02:00
commit c1f9546af6
32 changed files with 2165 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
// Package archtest innehåller arkitekturtester: regler om vilka paket som
// får bero på vad. Körs som vanliga tester med `go test ./...`.
package archtest
import (
"os/exec"
"strings"
"testing"
)
// Paket (relativt modulroten) som får importera Ebitengine. All annan kod
// under internal/ ska gå att testa utan fönster.
var mayUseEbitengine = map[string]bool{
"internal/game": true,
"internal/ui": true,
"internal/audio": true,
}
const ebitengine = "github.com/hajimehoshi/ebiten/"
func TestOnlyPresentationPackagesImportEbitengine(t *testing.T) {
root := goList(t, "-m", "-f", "{{.Dir}}")
module := goList(t, "-m", "-f", "{{.Path}}")
out := goListIn(t, root, "-f", "{{.ImportPath}}|{{join .Deps \",\"}}", "./...")
for _, line := range strings.Split(out, "\n") {
pkg, deps, ok := strings.Cut(line, "|")
if !ok {
continue
}
rel := strings.TrimPrefix(strings.TrimPrefix(pkg, module), "/")
if strings.HasPrefix(rel, "cmd/") || mayUseEbitengine[rel] {
continue
}
for _, d := range strings.Split(deps, ",") {
if strings.HasPrefix(d, ebitengine) {
t.Errorf("%s får inte bero på Ebitengine (via %s) – flytta logiken till ett rent paket eller lägg till paketet i mayUseEbitengine", rel, d)
break
}
}
}
}
func goList(t *testing.T, args ...string) string {
t.Helper()
return goListIn(t, "", args...)
}
func goListIn(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("go", append([]string{"list"}, args...)...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
t.Fatalf("go list %v: %v", args, err)
}
return strings.TrimSpace(string(out))
}

View File

@@ -0,0 +1,12 @@
// Package buildinfo innehåller värden som sätts av byggverktyget via -ldflags -X.
// Lämnas de orörda (t.ex. vid `go run` eller debugging via dlv) gäller standardvärdena.
package buildinfo
// Version är git-beskrivningen (t.ex. v0.1.0 eller v0.1.0-3-gabcdef-dirty) eller "dev".
var Version = "dev"
// Mode är "debug" eller "release".
var Mode = "debug"
// IsRelease rapporterar om binären byggdes i release-läge.
func IsRelease() bool { return Mode == "release" }

135
internal/game/game.go Normal file
View File

@@ -0,0 +1,135 @@
// Package game innehåller Ebitengine-spelloopen. Detta är det enda paketet
// (tillsammans med cmd/) som får känna till Ebitengine; all spellogik ska
// ligga i paket som går att testa utan fönster.
package game
import (
"fmt"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/buildinfo"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/level"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/internal/res"
)
// Intern upplösning. Allt ritas i denna storlek och skalas sedan upp med
// heltalsfaktor av Ebitengine så pixlarna förblir skarpa.
const (
ScreenW = 320
ScreenH = 180
TileSize = 8
)
// Game är Ebitengine-tillståndet för hela programmet.
type Game struct {
lvl *level.Level
player *ebiten.Image
tiles map[level.Tile]*ebiten.Image
resDir string
smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar (för smoke-test)
frames int
levelError error
}
// Options styr uppstarten.
type Options struct {
LevelID string // bana att visa, t.ex. "001-flygplatsen"
SmokeFrames int // >0 avslutar efter N uppdateringar
}
// New laddar resurser och bygger spelet.
func New(opt Options) (*Game, error) {
dir, err := res.Dir()
if err != nil {
return nil, err
}
g := &Game{resDir: dir, smokeLeft: opt.SmokeFrames}
img, err := res.LoadImage("sprites", "placeholder.png")
if err != nil {
return nil, err
}
g.player = ebiten.NewImageFromImage(img)
var raw []byte
if raw, err = res.ReadFile("data", "levels", opt.LevelID+".json"); err == nil {
g.lvl, err = level.Parse(raw)
}
if err != nil {
g.levelError = err
}
g.tiles = map[level.Tile]*ebiten.Image{}
for t, c := range map[level.Tile]color.RGBA{
level.Wall: {38, 43, 68, 255},
level.Floor: {139, 155, 180, 255},
level.Spawn: {99, 199, 77, 255},
level.Platform: {254, 174, 52, 255},
level.WrongPlatform: {228, 59, 68, 255},
level.Gate: {44, 232, 245, 255},
level.TicketMachine: {247, 118, 34, 255},
level.Sign: {255, 255, 255, 255},
} {
im := ebiten.NewImage(TileSize, TileSize)
im.Fill(c)
g.tiles[t] = im
}
return g, nil
}
// Update körs 60 ggr/s.
func (g *Game) Update() error {
g.frames++
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
return ebiten.Termination
}
if g.smokeLeft > 0 {
g.smokeLeft--
if g.smokeLeft == 0 {
return ebiten.Termination
}
}
return nil
}
// Draw ritar aktuell bild.
func (g *Game) Draw(screen *ebiten.Image) {
screen.Fill(color.RGBA{24, 20, 37, 255})
if g.lvl != nil {
// Centrera banan på skärmen (riktiga banor får kamera senare).
ox := (ScreenW - g.lvl.Width()*TileSize) / 2
oy := (ScreenH - g.lvl.Height()*TileSize) / 2
var op ebiten.DrawImageOptions
for y := 0; y < g.lvl.Height(); y++ {
for x := 0; x < g.lvl.Width(); x++ {
op.GeoM.Reset()
op.GeoM.Translate(float64(ox+x*TileSize), float64(oy+y*TileSize))
screen.DrawImage(g.tiles[g.lvl.At(x, y)], &op)
}
}
s := g.lvl.SpawnPoint()
op.GeoM.Reset()
op.GeoM.Translate(float64(ox+s.X*TileSize)-4, float64(oy+s.Y*TileSize)-8)
screen.DrawImage(g.player, &op)
}
name := "(ingen bana)"
if g.lvl != nil {
name = g.lvl.Name.Get("sv") + " -> " + g.lvl.Destination.Get("ja")
} else if g.levelError != nil {
name = "FEL: " + g.levelError.Error()
}
ebitenutil.DebugPrint(screen, fmt.Sprintf(
"JP Subway Pussel %s (%s) %.0f fps\nBana: %s\nResurser: %s\nESC avslutar",
buildinfo.Version, buildinfo.Mode, ebiten.ActualFPS(), name, g.resDir))
}
// Layout talar om för Ebitengine vilken intern upplösning vi ritar i.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return ScreenW, ScreenH
}

228
internal/level/level.go Normal file
View File

@@ -0,0 +1,228 @@
// Package level definierar banformatet och ren spellogik kring det
// (validering, framkomlighet). Paketet importerar inte Ebitengine och
// kan därför testas utan fönster.
//
// Banor lagras som JSON i resources/data/levels/. Rutnätet är en lista av
// lika långa strängar där varje tecken är en ruta (se Tile-konstanterna).
package level
import (
"encoding/json"
"fmt"
"strings"
)
// Tile är ett tecken i rutnätet.
type Tile byte
// Tecknen som får förekomma i ett rutnät.
const (
Wall Tile = '#' // vägg, ej passerbar
Floor Tile = '.' // golv
Spawn Tile = 'S' // startpunkt (exakt en)
Platform Tile = 'P' // perrong med ett tåg (målet om tåget är rätt)
WrongPlatform Tile = 'p' // perrong med fel tåg
Gate Tile = 'G' // spärr (kräver kort/biljett)
TicketMachine Tile = 'T' // biljett-/påfyllningsautomat
Sign Tile = 'i' // skylt
)
var knownTiles = map[Tile]bool{
Wall: true, Floor: true, Spawn: true, Platform: true, WrongPlatform: true,
Gate: true, TicketMachine: true, Sign: true,
}
// Passable rapporterar om en ruta går att gå på (utan hänsyn till spärrar).
func (t Tile) Passable() bool { return t != Wall }
// Text är en flerspråkig sträng, t.ex. {"sv": "Flygplatsen", "en": "The Airport", "ja": "空港"}.
type Text map[string]string
// Get hämtar texten på ett språk med fallback till engelska, sedan vilket som helst.
func (t Text) Get(lang string) string {
if s, ok := t[lang]; ok {
return s
}
if s, ok := t["en"]; ok {
return s
}
for _, s := range t {
return s
}
return ""
}
// Train är ett tåg (eller en buss) som avgår från en perrong i banan.
type Train struct {
Platform string `json:"platform"` // vilket perrong-tecken (P/p) tåget står vid
Line Text `json:"line"` // linjens namn
Destination Text `json:"destination"` // slutstation
Correct bool `json:"correct"` // tar spelaren till rätt resmål
}
// Level är en bana.
type Level struct {
ID string `json:"id"`
Name Text `json:"name"`
Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår)
Destination Text `json:"destination"` // dit spelaren ska
TimeLimitSec int `json:"timeLimitSec"` // 0 = ingen tidsgräns
Grid []string `json:"grid"`
Trains []Train `json:"trains"`
}
// Point är en rutkoordinat.
type Point struct{ X, Y int }
// Parse avkodar och validerar en bana.
func Parse(data []byte) (*Level, error) {
var l Level
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("level: ogiltig JSON: %w", err)
}
if err := l.Validate(); err != nil {
return nil, err
}
return &l, nil
}
// Width och Height i rutor.
func (l *Level) Width() int {
if len(l.Grid) == 0 {
return 0
}
return len(l.Grid[0])
}
func (l *Level) Height() int { return len(l.Grid) }
// At returnerar rutan på (x, y); utanför kartan räknas som vägg.
func (l *Level) At(x, y int) Tile {
if y < 0 || y >= len(l.Grid) || x < 0 || x >= len(l.Grid[y]) {
return Wall
}
return Tile(l.Grid[y][x])
}
// Find returnerar alla rutor av en viss typ.
func (l *Level) Find(t Tile) []Point {
var pts []Point
for y, row := range l.Grid {
for x := 0; x < len(row); x++ {
if Tile(row[x]) == t {
pts = append(pts, Point{x, y})
}
}
}
return pts
}
// SpawnPoint returnerar startrutan (valideringen garanterar att den finns).
func (l *Level) SpawnPoint() Point { return l.Find(Spawn)[0] }
// Validate kontrollerar att banan är rimlig: rektangulär, bara kända tecken,
// exakt en start, minst en perrong, och att varje tåg står vid ett
// perrong-tecken som finns i rutnätet.
func (l *Level) Validate() error {
if l.ID == "" {
return fmt.Errorf("level: saknar id")
}
if len(l.Grid) == 0 {
return fmt.Errorf("level %s: tomt rutnät", l.ID)
}
w := len(l.Grid[0])
spawns, platforms := 0, 0
for y, row := range l.Grid {
if len(row) != w {
return fmt.Errorf("level %s: rad %d har längd %d, förväntade %d", l.ID, y, len(row), w)
}
for x := 0; x < len(row); x++ {
t := Tile(row[x])
if !knownTiles[t] {
return fmt.Errorf("level %s: okänt tecken %q på (%d,%d)", l.ID, row[x], x, y)
}
switch t {
case Spawn:
spawns++
case Platform, WrongPlatform:
platforms++
}
}
}
if spawns != 1 {
return fmt.Errorf("level %s: förväntade exakt en start (S), hittade %d", l.ID, spawns)
}
if platforms == 0 {
return fmt.Errorf("level %s: saknar perronger (P/p)", l.ID)
}
if len(l.Trains) == 0 {
return fmt.Errorf("level %s: saknar tåg", l.ID)
}
correct := 0
for i, tr := range l.Trains {
if len(tr.Platform) != 1 || (Tile(tr.Platform[0]) != Platform && Tile(tr.Platform[0]) != WrongPlatform) {
return fmt.Errorf("level %s: tåg %d har ogiltig perrong %q", l.ID, i, tr.Platform)
}
if !strings.Contains(strings.Join(l.Grid, ""), tr.Platform) {
return fmt.Errorf("level %s: tåg %d står vid perrong %q som inte finns i rutnätet", l.ID, i, tr.Platform)
}
if tr.Correct {
correct++
}
}
if correct == 0 {
return fmt.Errorf("level %s: inget tåg är markerat som rätt", l.ID)
}
return nil
}
// Reachable returnerar alla rutor som nås från start via passerbara rutor
// (4-riktningars BFS). Spärrar räknas som passerbara.
func (l *Level) Reachable() map[Point]bool {
start := l.SpawnPoint()
seen := map[Point]bool{start: true}
queue := []Point{start}
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
for _, d := range [...]Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
n := Point{p.X + d.X, p.Y + d.Y}
if seen[n] || !l.At(n.X, n.Y).Passable() {
continue
}
seen[n] = true
queue = append(queue, n)
}
}
return seen
}
// Solvable rapporterar om minst en perrong med ett rätt tåg kan nås från start.
func (l *Level) Solvable() bool {
reach := l.Reachable()
for _, tr := range l.Trains {
if !tr.Correct {
continue
}
for _, p := range l.Find(Tile(tr.Platform[0])) {
if reach[p] {
return true
}
}
}
return false
}
// Unreachable listar interaktiva rutor (perronger, automater, skyltar) som
// inte går att nå – praktiskt som varning när man bygger banor.
func (l *Level) Unreachable() []Point {
reach := l.Reachable()
var out []Point
for _, t := range []Tile{Platform, WrongPlatform, TicketMachine, Sign, Gate} {
for _, p := range l.Find(t) {
if !reach[p] {
out = append(out, p)
}
}
}
return out
}

View File

@@ -0,0 +1,137 @@
package level
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.brasse-pc.eu/brasse/jp-subway-pussel/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),
"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)
}
})
}
}

135
internal/res/res.go Normal file
View File

@@ -0,0 +1,135 @@
// Package res hittar och läser resurser (sprites, ljud, text, speldata).
//
// Resursmappen ligger normalt bredvid binären (`<exe-mapp>/resources`). Vid
// utveckling (`go run`, dlv) ligger binären i en temp-mapp, så vi faller
// tillbaka på arbetskatalogen och letar uppåt efter repots rot (go.mod).
// Miljövariabeln JP_RESOURCES vinner alltid.
package res
import (
"encoding/json"
"errors"
"fmt"
"image"
_ "image/png" // registrerar PNG-avkodaren för image.Decode
"os"
"path/filepath"
"sync"
)
// EnvVar är miljövariabeln som pekar ut resursmappen explicit.
const EnvVar = "JP_RESOURCES"
// DirName är namnet på resursmappen bredvid binären / i repots rot.
const DirName = "resources"
var (
dirOnce sync.Once
dirPath string
dirErr error
)
// Dir returnerar den absoluta sökvägen till resursmappen. Resultatet cachas.
func Dir() (string, error) {
dirOnce.Do(func() {
exe, _ := os.Executable()
cwd, _ := os.Getwd()
dirPath, dirErr = Locate(os.Getenv(EnvVar), filepath.Dir(exe), cwd, dirExists)
})
return dirPath, dirErr
}
// Locate är den rena uppslagslogiken bakom Dir, bruten ut för att kunna testas.
// Ordning: env-variabel, mappen bredvid binären, arbetskatalogen och dess
// föräldrar (stannar vid första som innehåller en resursmapp).
func Locate(env, exeDir, cwd string, exists func(string) bool) (string, error) {
if env != "" {
if exists(env) {
return filepath.Clean(env), nil
}
return "", fmt.Errorf("res: %s=%q pekar inte på en mapp", EnvVar, env)
}
var tried []string
if exeDir != "" {
c := filepath.Join(exeDir, DirName)
if exists(c) {
return c, nil
}
tried = append(tried, c)
}
for dir := cwd; dir != ""; {
c := filepath.Join(dir, DirName)
if exists(c) {
return c, nil
}
tried = append(tried, c)
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("res: hittade ingen %q-mapp (sökte: %v); sätt %s", DirName, tried, EnvVar)
}
func dirExists(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
// Path bygger en sökväg inuti resursmappen, t.ex. Path("sprites", "player.png").
func Path(parts ...string) (string, error) {
d, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(append([]string{d}, parts...)...), nil
}
// ReadFile läser en fil relativt resursmappen.
func ReadFile(parts ...string) ([]byte, error) {
p, err := Path(parts...)
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("res: %w", err)
}
return b, nil
}
// LoadJSON läser och avkodar en JSON-fil relativt resursmappen.
func LoadJSON(v any, parts ...string) error {
b, err := ReadFile(parts...)
if err != nil {
return err
}
if err := json.Unmarshal(b, v); err != nil {
return fmt.Errorf("res: %s: %w", filepath.Join(parts...), err)
}
return nil
}
// LoadImage läser en PNG relativt resursmappen och returnerar en image.Image.
// (Konvertering till *ebiten.Image görs av anroparen så att detta paket
// förblir fritt från Ebitengine och kan testas utan fönster.)
func LoadImage(parts ...string) (image.Image, error) {
p, err := Path(parts...)
if err != nil {
return nil, err
}
f, err := os.Open(p)
if err != nil {
return nil, fmt.Errorf("res: %w", err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, fmt.Errorf("res: %s: %w", p, err)
}
return img, nil
}
// ErrNotFound kan användas av anropare för att skilja "saknas" från andra fel.
var ErrNotFound = errors.New("res: resursen saknas")

76
internal/res/res_test.go Normal file
View File

@@ -0,0 +1,76 @@
package res
import (
"path/filepath"
"strings"
"testing"
)
func existsIn(paths ...string) func(string) bool {
set := map[string]bool{}
for _, p := range paths {
set[filepath.Clean(p)] = true
}
return func(p string) bool { return set[filepath.Clean(p)] }
}
func TestLocate_EnvWins(t *testing.T) {
got, err := Locate("/opt/custom", "/bin", "/work", existsIn("/opt/custom", "/bin/resources"))
if err != nil || got != "/opt/custom" {
t.Fatalf("got %q, %v; want /opt/custom", got, err)
}
}
func TestLocate_EnvMissingIsError(t *testing.T) {
_, err := Locate("/nope", "/bin", "/work", existsIn("/bin/resources"))
if err == nil || !strings.Contains(err.Error(), EnvVar) {
t.Fatalf("expected error mentioning %s, got %v", EnvVar, err)
}
}
func TestLocate_ExeDirBeforeCwd(t *testing.T) {
got, err := Locate("", "/app", "/work", existsIn("/app/resources", "/work/resources"))
if err != nil || got != filepath.Join("/app", DirName) {
t.Fatalf("got %q, %v", got, err)
}
}
func TestLocate_WalksUpFromCwd(t *testing.T) {
// Som vid `go run`/dlv: binären i en temp-mapp, cwd djupt inne i repot.
got, err := Locate("", "/tmp/go-build123", "/repo/internal/level", existsIn("/repo/resources"))
if err != nil || got != filepath.Join("/repo", DirName) {
t.Fatalf("got %q, %v", got, err)
}
}
func TestLocate_NothingFound(t *testing.T) {
_, err := Locate("", "/a", "/b/c", existsIn())
if err == nil {
t.Fatal("expected error")
}
}
// TestDir_RealRepo kör mot det riktiga repot: testet körs med cwd = paketmappen,
// så uppsökningen måste hitta <repo>/resources.
func TestDir_RealRepo(t *testing.T) {
d, err := Dir()
if err != nil {
t.Fatal(err)
}
if filepath.Base(d) != DirName {
t.Fatalf("unexpected dir %q", d)
}
if _, err := ReadFile("text", "sv.json"); err != nil {
t.Fatalf("resources/text/sv.json ska finnas: %v", err)
}
var m map[string]string
if err := LoadJSON(&m, "text", "sv.json"); err != nil {
t.Fatal(err)
}
if m["game.title"] == "" {
t.Fatal("sv.json saknar game.title")
}
if _, err := LoadImage("sprites", "placeholder.png"); err != nil {
t.Fatal(err)
}
}

48
internal/rng/rng.go Normal file
View File

@@ -0,0 +1,48 @@
// Package rng ger seedade slumptalsgeneratorer.
//
// Allt som ska vara reproducerbart (minispel, oändligt läge, event-timing)
// måste ta sin slump härifrån med en känd seed, aldrig från den globala
// generatorn. Seeden sparas i sparfilen så att en laddad omgång ger samma
// utfall som den avbrutna.
package rng
import (
crand "crypto/rand"
"encoding/binary"
"math/rand/v2"
)
// Seed är en 64-bitars seed. Två värden för att kunna avleda del-seeds
// (t.ex. per bana eller per minispel) utan att de påverkar varandra.
type Seed struct {
A, B uint64
}
// NewSeed drar en färsk slumpmässig seed från operativsystemet.
func NewSeed() Seed {
var b [16]byte
if _, err := crand.Read(b[:]); err != nil {
panic("rng: kunde inte läsa slump från OS: " + err.Error())
}
return Seed{binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:])}
}
// FromInt skapar en seed från ett enkelt heltal (praktiskt i tester och för
// seeds som spelaren skriver in manuellt).
func FromInt(n uint64) Seed { return Seed{A: n, B: n ^ 0x9E3779B97F4A7C15} }
// Derive avleder en deterministisk del-seed för en namngiven kontext, så att
// t.ex. biljettautomatens minispel på bana 3 alltid får samma slump oavsett
// vad spelaren gjort innan.
func (s Seed) Derive(context string) Seed {
h := rand.New(rand.NewPCG(s.A, s.B))
a, b := h.Uint64(), h.Uint64()
for _, c := range context {
a ^= uint64(c) * 0x100000001B3
b = b*31 + uint64(c)
}
return Seed{A: a, B: b}
}
// New returnerar en generator för seeden. Samma seed ger alltid samma sekvens.
func (s Seed) New() *rand.Rand { return rand.New(rand.NewPCG(s.A, s.B)) }

38
internal/rng/rng_test.go Normal file
View File

@@ -0,0 +1,38 @@
package rng
import "testing"
func TestDeterministic(t *testing.T) {
a := FromInt(42).New()
b := FromInt(42).New()
for i := 0; i < 1000; i++ {
if x, y := a.Uint64(), b.Uint64(); x != y {
t.Fatalf("sekvensen skiljer sig vid %d: %d != %d", i, x, y)
}
}
}
func TestDifferentSeedsDiffer(t *testing.T) {
if FromInt(1).New().Uint64() == FromInt(2).New().Uint64() {
t.Fatal("olika seeds gav samma första värde")
}
}
func TestDeriveIsStableAndContextual(t *testing.T) {
s := FromInt(7)
if s.Derive("ticket") != s.Derive("ticket") {
t.Fatal("Derive är inte deterministisk")
}
if s.Derive("ticket") == s.Derive("toilet") {
t.Fatal("olika kontexter gav samma del-seed")
}
if s.Derive("ticket") == FromInt(8).Derive("ticket") {
t.Fatal("olika rot-seeds gav samma del-seed")
}
}
func TestNewSeedIsRandom(t *testing.T) {
if NewSeed() == NewSeed() {
t.Fatal("två NewSeed() gav samma värde")
}
}